Skip to main content

cpm_planner/
plan.rs

1//! SPEC §33 PA1 — `Planner` data model.
2//!
3//! This module defines the *types* the [`Planner`](crate::ports::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//! This crate ships a textbook critical-path-method implementation of the
9//! contract. The `Planner` trait is the seam: the runtime always holds an
10//! `Arc<dyn Planner>`, so operators can register whichever implementation
11//! suits their deployment.
12//!
13//! # Wire format
14//!
15//! Every data type derives `Serialize` + `Deserialize` because the MCP server
16//! layer (PA4) serialises plans, cohorts, and status snapshots into JSON-RPC
17//! responses. The unit test at the bottom of this file exercises a one-
18//! deliverable round-trip as a smoke test for the wire shape.
19//!
20//! # Locking semantics summary
21//!
22//! - A `PlanGraph` is submitted once; the implementation hashes
23//!   `(graph, caller)` and returns an existing [`PlanId`] on resubmit. Calls
24//!   are idempotent.
25//! - [`crate::ports::Planner::acquire_cohort`] returns a [`Cohort`]: a batch
26//!   of deliverables whose prerequisites are all [`DeliverableStatus::Complete`]
27//!   and whose owned-file claims do not conflict with each other or with any
28//!   currently held lock (exclusive conflicts with anything; append/append
29//!   may share). The batch is locked atomically (PA3 guarantees this).
30//! - [`crate::ports::Planner::mark_status`] with `Complete` or `Failed`
31//!   releases the lock. A caller-id mismatch on the held lock yields
32//!   [`PlannerError::LockNotHeld`].
33//! - [`crate::ports::Planner::heartbeat`] refreshes the TTL; the TTL itself is
34//!   an implementation parameter (PA3 sets the open-source default at 5 min).
35//! - [`crate::ports::Planner::force_release`] is the operator escape hatch.
36//!   Implementations MUST emit an audit event carrying the supplied `reason`.
37
38use std::path::{Path, PathBuf};
39use std::time::Duration;
40
41use chrono::{DateTime, Utc};
42use serde::{Deserialize, Serialize};
43use thiserror::Error;
44
45use crate::earned_value::{BaselineTaken, PlanBaseline};
46
47/// Opaque plan identifier returned by [`crate::ports::Planner::submit_plan`].
48///
49/// The string form is implementation-defined (UUID, deterministic content
50/// hash, ULID, etc.). Callers treat the value as opaque and round-trip it
51/// without inspecting the contents.
52#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
53pub struct PlanId(pub String);
54
55impl PlanId {
56    /// Borrow the underlying string for logging or hashing.
57    pub fn as_str(&self) -> &str {
58        &self.0
59    }
60}
61
62impl std::fmt::Display for PlanId {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.write_str(&self.0)
65    }
66}
67
68/// How a caller names the plan it wants resolved (see
69/// [`crate::ports::Planner::resolve_plan`]).
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum PlanRef {
72    /// The plan with this exact id, returned unchanged without a lookup.
73    Id(PlanId),
74    /// The selected variant of the plan line `(project, name)`.
75    Line { project: String, name: String },
76    /// The project's single active plan line: its selected variant's plan.
77    Active { project: String },
78}
79
80/// Opaque per-orchestrator identity. The Planner uses this to verify that
81/// the caller releasing a lock is the same caller who acquired it.
82///
83/// The string form is implementation-defined; it must be stable for the
84/// lifetime of a single orchestrator session.
85#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
86pub struct CallerId(pub String);
87
88impl CallerId {
89    pub fn as_str(&self) -> &str {
90        &self.0
91    }
92}
93
94impl std::fmt::Display for CallerId {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        f.write_str(&self.0)
97    }
98}
99
100/// Full plan submitted to [`crate::ports::Planner::submit_plan`].
101///
102/// A plan is a DAG of [`Deliverable`]s connected by the
103/// `prerequisites` field. The Planner is responsible for detecting cycles,
104/// missing prerequisite references, and duplicate ids; on any structural
105/// problem it returns [`PlannerError::InvalidGraph`] with a precise
106/// `reason`.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct PlanGraph {
109    /// Every deliverable in the plan. Order is irrelevant; the Planner
110    /// derives execution order from the `prerequisites` edges.
111    pub deliverables: Vec<Deliverable>,
112
113    /// Optional global guardrail: maximum number of deliverables that may
114    /// be dispatched in one chained sequence before the orchestrator must
115    /// pause for explicit re-prompt. `None` means no limit. Carried at the
116    /// graph level rather than per-deliverable because it reflects an
117    /// operator policy, not a property of any single task.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub max_chained_dispatch: Option<u32>,
120}
121
122/// What a prerequisite edge hands over: a finished artifact or an interface.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum PrerequisiteKind {
126    Artifact,
127    Interface,
128}
129
130/// A prerequisite edge. Wire: a bare id string, or an object.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(untagged, deny_unknown_fields)]
133pub enum Prerequisite {
134    Id(String),
135    Edge {
136        id: String,
137        #[serde(default, skip_serializing_if = "Option::is_none")]
138        consumes: Option<String>,
139        #[serde(default, skip_serializing_if = "Option::is_none")]
140        kind: Option<PrerequisiteKind>,
141        #[serde(default, skip_serializing_if = "Option::is_none")]
142        lag_hours: Option<f32>,
143    },
144}
145
146impl Prerequisite {
147    /// Id of the deliverable this edge points at.
148    pub fn id(&self) -> &str {
149        match self {
150            Self::Id(id) | Self::Edge { id, .. } => id,
151        }
152    }
153
154    /// What the dependent consumes from the prerequisite, if stated.
155    pub fn consumes(&self) -> Option<&str> {
156        match self {
157            Self::Id(_) => None,
158            Self::Edge { consumes, .. } => consumes.as_deref(),
159        }
160    }
161
162    /// Edge kind, if stated.
163    pub fn kind(&self) -> Option<PrerequisiteKind> {
164        match self {
165            Self::Id(_) => None,
166            Self::Edge { kind, .. } => *kind,
167        }
168    }
169
170    /// Hours between the prerequisite finishing and the dependent starting;
171    /// `0.0` when absent.
172    pub fn lag_hours(&self) -> f32 {
173        match self {
174            Self::Id(_) => 0.0,
175            Self::Edge { lag_hours, .. } => lag_hours.unwrap_or(0.0),
176        }
177    }
178}
179
180impl From<&str> for Prerequisite {
181    fn from(id: &str) -> Self {
182        Self::Id(id.to_string())
183    }
184}
185
186impl From<String> for Prerequisite {
187    fn from(id: String) -> Self {
188        Self::Id(id)
189    }
190}
191
192/// How a deliverable claims a file.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
194#[serde(rename_all = "snake_case")]
195pub enum FileMode {
196    /// Sole writer; conflicts with every other claim on the path.
197    #[default]
198    Exclusive,
199    /// Append-only; may be co-leased with other append claims.
200    Append,
201}
202
203/// One entry of `owned_files`: a bare path (exclusive) or `{path, mode}`.
204/// A bare path round-trips as a plain string.
205#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
206#[serde(untagged, from = "OwnedFileWire")]
207pub enum OwnedFile {
208    Path(PathBuf),
209    Claim {
210        path: PathBuf,
211        #[serde(default, skip_serializing_if = "Option::is_none")]
212        mode: Option<FileMode>,
213    },
214}
215
216/// Strict wire form of [`OwnedFile`]: the object form rejects unknown keys.
217#[derive(Deserialize)]
218#[serde(untagged)]
219enum OwnedFileWire {
220    Path(PathBuf),
221    Claim(OwnedClaimWire),
222}
223
224#[derive(Deserialize)]
225#[serde(deny_unknown_fields)]
226struct OwnedClaimWire {
227    path: PathBuf,
228    #[serde(default)]
229    mode: Option<FileMode>,
230}
231
232impl From<OwnedFileWire> for OwnedFile {
233    fn from(w: OwnedFileWire) -> Self {
234        match w {
235            OwnedFileWire::Path(p) => Self::Path(p),
236            OwnedFileWire::Claim(c) => Self::Claim {
237                path: c.path,
238                mode: c.mode,
239            },
240        }
241    }
242}
243
244impl OwnedFile {
245    pub fn path(&self) -> &Path {
246        match self {
247            Self::Path(p) | Self::Claim { path: p, .. } => p,
248        }
249    }
250
251    pub fn mode(&self) -> FileMode {
252        match self {
253            Self::Path(_) => FileMode::Exclusive,
254            Self::Claim { mode, .. } => mode.unwrap_or_default(),
255        }
256    }
257}
258
259impl From<&str> for OwnedFile {
260    fn from(s: &str) -> Self {
261        Self::Path(PathBuf::from(s))
262    }
263}
264
265impl From<PathBuf> for OwnedFile {
266    fn from(p: PathBuf) -> Self {
267        Self::Path(p)
268    }
269}
270
271/// Optional three-point effort estimate for a [`Deliverable`].
272///
273/// The three points must satisfy `0 <= optimistic <= likely <= pessimistic`
274/// and all be finite and at most [`MAX_HOURS`]; [`crate::planner`] rejects
275/// violations as [`PlannerError::InvalidGraph`].
276#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
277#[serde(deny_unknown_fields)]
278pub struct Estimate {
279    /// Best-case effort in hours.
280    pub optimistic: f32,
281    /// Most-likely effort in hours; used as the scheduled length when no
282    /// explicit effort or duration is set.
283    pub likely: f32,
284    /// Worst-case effort in hours.
285    pub pessimistic: f32,
286}
287
288/// How partial progress on a deliverable converts to earned percent for
289/// earned value ([`Deliverable::earning_rule`]).
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
291#[serde(rename_all = "snake_case")]
292pub enum EarningRule {
293    /// 100% only when Complete.
294    #[default]
295    ZeroHundred,
296    /// 50% once started (or any percent reported), 100% when Complete.
297    FiftyFifty,
298    /// The reported percent, 100% when Complete.
299    Weighted,
300}
301
302/// A single unit of work scheduled by the Planner.
303///
304/// `owned_files` is the load-bearing field for concurrent dispatch: the
305/// Planner guarantees that two deliverables with conflicting `owned_files`
306/// claims (exclusive conflicts with anything; append/append may share) will
307/// never be returned in the same [`Cohort`] and will never both hold active
308/// locks. This is the only mechanism the Planner uses to prevent
309/// write-write conflicts; implementations of [`crate::ports::Planner`]
310/// must therefore reject any plan that contains a deliverable whose
311/// `owned_files` are not specified up front.
312#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
313pub struct Deliverable {
314    /// Unique identifier within the plan. The Planner rejects duplicate
315    /// ids at submit time with [`PlannerError::InvalidGraph`].
316    pub id: String,
317
318    /// Exact file paths the implementer is going to write while completing
319    /// this deliverable. The absence of conflicting claims across deliverables
320    /// is the lock-contention invariant; see [`crate::ports::Planner::acquire_cohort`] semantics.
321    pub owned_files: Vec<OwnedFile>,
322
323    /// Ids of other deliverables in the same plan that must reach
324    /// [`DeliverableStatus::Complete`] before this one becomes eligible
325    /// for acquisition.
326    pub prerequisites: Vec<Prerequisite>,
327
328    /// Estimated wall-clock effort, used by critical-path math in
329    /// [`PlanStatus::critical_path`]. `None` means the planner derives an
330    /// estimate with `EffortEstimator`.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub estimated_effort_hours: Option<f32>,
333
334    /// Calendar time the deliverable occupies on the schedule, in hours.
335    /// When set it replaces the effort estimate as the scheduled length;
336    /// effort stays the cost basis. Must be finite and >= 0.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub duration_hours: Option<f32>,
339
340    /// Optional three-point effort estimate. Scheduled-length precedence is
341    /// `duration_hours` > `estimated_effort_hours` > `estimate.likely` >
342    /// `0` for a milestone > the estimator's default. So only when neither
343    /// `duration_hours` nor `estimated_effort_hours` is set is `likely` the
344    /// scheduled length and the basis for cost and DRAG, and only then does
345    /// Monte Carlo sample the estimate.
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub estimate: Option<Estimate>,
348
349    /// Free-form metadata. Conventionally carries model hints, human
350    /// descriptions, links to specs, etc. The Planner does not interpret
351    /// this field.
352    #[serde(default)]
353    pub metadata: serde_json::Value,
354
355    /// True for a milestone: a zero-effort marker whose schedule and
356    /// critical path `plan.status` reports in `milestones`. A deliverable
357    /// with `metadata.milestone == true` is treated the same way.
358    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
359    pub milestone: bool,
360
361    /// How earned value credits partial progress. `None` means
362    /// [`EarningRule::ZeroHundred`]; an explicit `zero_hundred` deserializes
363    /// as `None` and hashes like it. Part of the plan's identity (hashed).
364    #[serde(
365        default,
366        skip_serializing_if = "Option::is_none",
367        deserialize_with = "deserialize_earning_rule"
368    )]
369    pub earning_rule: Option<EarningRule>,
370}
371
372/// The default rule is normalised to `None`, so `"zero_hundred"` and an
373/// absent rule describe the same deliverable.
374fn deserialize_earning_rule<'de, D>(d: D) -> Result<Option<EarningRule>, D::Error>
375where
376    D: serde::Deserializer<'de>,
377{
378    Ok(Option::<EarningRule>::deserialize(d)?.filter(|r| *r != EarningRule::ZeroHundred))
379}
380
381impl Deliverable {
382    /// Whether this deliverable is a milestone, via the `milestone` field or
383    /// the legacy `metadata.milestone == true` convention.
384    pub fn is_milestone(&self) -> bool {
385        self.milestone
386            || self
387                .metadata
388                .get("milestone")
389                .and_then(serde_json::Value::as_bool)
390                .unwrap_or(false)
391    }
392}
393
394/// Lifecycle state of a single [`Deliverable`].
395///
396/// Transitions are driven by [`crate::ports::Planner::mark_status`] and by
397/// the Planner's own scheduling logic (e.g. `Pending` -> `Ready` when the
398/// last prerequisite completes).
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(tag = "status", rename_all = "snake_case")]
401pub enum DeliverableStatus {
402    /// At least one prerequisite is not yet complete.
403    Pending,
404    /// All prerequisites are complete; not yet acquired.
405    Ready,
406    /// A caller holds an active lock and is working on this deliverable.
407    InProgress,
408    /// The caller marked this deliverable complete. The lock has been
409    /// released.
410    Complete,
411    /// The caller marked this deliverable failed. The lock has been
412    /// released. The reason is preserved for audit and for human / planner
413    /// retry decisions.
414    Failed {
415        /// Human-readable failure reason supplied by the caller.
416        reason: String,
417    },
418}
419
420/// Request bundle for [`crate::ports::Planner::acquire_cohort`].
421///
422/// Public fields mirror the historical positional arguments so callers can
423/// construct it directly or via [`AcquireRequest::new`].
424#[derive(Debug, Clone)]
425pub struct AcquireRequest {
426    pub plan_id: PlanId,
427    pub caller_id: CallerId,
428    pub max_count: usize,
429    /// When set, only these deliverables are considered; each one that is
430    /// not leased is reported in `Cohort.blocked` with a reason code.
431    pub ids: Option<Vec<String>>,
432    /// When set, only deliverables whose `metadata` has every `(key, value)`
433    /// pair (JSON equality) are considered. Non-matches are silently skipped.
434    pub metadata_filter: Option<serde_json::Map<String, serde_json::Value>>,
435    /// Requested lease TTL for this call. `None` uses the planner
436    /// default; the planner clamps the value to its configured maximum.
437    pub ttl: Option<Duration>,
438}
439
440impl AcquireRequest {
441    pub fn new(plan_id: PlanId, caller_id: CallerId, max_count: usize) -> Self {
442        Self {
443            plan_id,
444            caller_id,
445            max_count,
446            ids: None,
447            metadata_filter: None,
448            ttl: None,
449        }
450    }
451
452    /// Restrict the acquire to these deliverable ids.
453    pub fn with_ids(mut self, ids: Vec<String>) -> Self {
454        self.ids = Some(ids);
455        self
456    }
457
458    /// Restrict the acquire to deliverables matching these metadata pairs.
459    pub fn with_metadata_filter(
460        mut self,
461        filter: serde_json::Map<String, serde_json::Value>,
462    ) -> Self {
463        self.metadata_filter = Some(filter);
464        self
465    }
466
467    /// Request a lease TTL for this acquire. `None` (the default) uses
468    /// the planner default TTL; the planner clamps the value to its
469    /// configured maximum.
470    pub fn with_ttl(mut self, ttl: Duration) -> Self {
471        self.ttl = Some(ttl);
472        self
473    }
474}
475
476/// Request bundle for [`crate::ports::Planner::mark_status`].
477///
478/// The optional progress fields feed earned value and are validated as
479/// `INVALID_ACTUALS`: `earned_pct` is 0..=100 and only accepted with
480/// `InProgress` (with `Complete` it is accepted and ignored),
481/// `actual_effort_hours` is finite and in `0..=`[`MAX_HOURS`], and
482/// `evidence` is at most [`MAX_EVIDENCE_CHARS`] characters, with at most
483/// [`MAX_EVIDENCE_ENTRIES`] kept per deliverable.
484#[derive(Debug, Clone)]
485pub struct MarkStatusRequest {
486    pub plan_id: PlanId,
487    pub deliverable_id: String,
488    pub caller_id: CallerId,
489    pub status: DeliverableStatus,
490    /// Reported percent complete; replaces the stored value.
491    pub earned_pct: Option<u8>,
492    /// Reported total effort so far, in hours; replaces the stored value and
493    /// takes precedence over leased hours for actual cost.
494    pub actual_effort_hours: Option<f32>,
495    /// One evidence note, appended to the deliverable's evidence list.
496    pub evidence: Option<String>,
497}
498
499impl MarkStatusRequest {
500    pub fn new(
501        plan_id: PlanId,
502        deliverable_id: impl Into<String>,
503        caller_id: CallerId,
504        status: DeliverableStatus,
505    ) -> Self {
506        Self {
507            plan_id,
508            deliverable_id: deliverable_id.into(),
509            caller_id,
510            status,
511            earned_pct: None,
512            actual_effort_hours: None,
513            evidence: None,
514        }
515    }
516
517    /// Report the percent complete (0..=100).
518    pub fn with_earned_pct(mut self, pct: u8) -> Self {
519        self.earned_pct = Some(pct);
520        self
521    }
522
523    /// Report the total effort spent so far, in hours.
524    pub fn with_actual_effort_hours(mut self, hours: f32) -> Self {
525        self.actual_effort_hours = Some(hours);
526        self
527    }
528
529    /// Append one evidence note.
530    pub fn with_evidence(mut self, evidence: impl Into<String>) -> Self {
531        self.evidence = Some(evidence.into());
532        self
533    }
534}
535
536/// Request bundle for [`crate::ports::Planner::heartbeat`].
537#[derive(Debug, Clone)]
538pub struct HeartbeatRequest {
539    pub plan_id: PlanId,
540    pub deliverable_id: String,
541    pub caller_id: CallerId,
542    /// Requested lease TTL for this heartbeat. `None` uses the planner
543    /// default; the planner clamps the value to its configured maximum.
544    pub ttl: Option<Duration>,
545}
546
547impl HeartbeatRequest {
548    pub fn new(plan_id: PlanId, deliverable_id: impl Into<String>, caller_id: CallerId) -> Self {
549        Self {
550            plan_id,
551            deliverable_id: deliverable_id.into(),
552            caller_id,
553            ttl: None,
554        }
555    }
556
557    /// Request a lease TTL for this heartbeat. `None` (the default)
558    /// uses the planner default TTL; the planner clamps the value to
559    /// its configured maximum.
560    pub fn with_ttl(mut self, ttl: Duration) -> Self {
561        self.ttl = Some(ttl);
562        self
563    }
564}
565
566/// Request bundle for [`crate::ports::Planner::force_release`].
567#[derive(Debug, Clone)]
568pub struct ForceReleaseRequest {
569    pub plan_id: PlanId,
570    pub deliverable_id: String,
571    pub reason: String,
572    /// Also clear the deliverable's lapse and failure counters (revives a
573    /// lapse-limited or circuit-broken deliverable). Defaults to `false`.
574    pub reset_counters: bool,
575}
576
577impl ForceReleaseRequest {
578    pub fn new(
579        plan_id: PlanId,
580        deliverable_id: impl Into<String>,
581        reason: impl Into<String>,
582    ) -> Self {
583        Self {
584            plan_id,
585            deliverable_id: deliverable_id.into(),
586            reason: reason.into(),
587            reset_counters: false,
588        }
589    }
590
591    /// Set whether the lapse and failure counters are cleared too.
592    pub fn reset_counters(mut self, yes: bool) -> Self {
593        self.reset_counters = yes;
594        self
595    }
596}
597
598/// Request bundle for [`crate::ports::Planner::accept`].
599#[derive(Debug, Clone)]
600pub struct AcceptRequest {
601    pub plan_id: PlanId,
602    pub deliverable_id: String,
603    pub accepted_by: String,
604    pub evidence: String,
605    /// Take over a live lease held by someone else. Defaults to `false`.
606    pub override_lock: bool,
607}
608
609impl AcceptRequest {
610    pub fn new(
611        plan_id: PlanId,
612        deliverable_id: impl Into<String>,
613        accepted_by: impl Into<String>,
614        evidence: impl Into<String>,
615    ) -> Self {
616        Self {
617            plan_id,
618            deliverable_id: deliverable_id.into(),
619            accepted_by: accepted_by.into(),
620            evidence: evidence.into(),
621            override_lock: false,
622        }
623    }
624
625    /// Set whether a live lease held by another caller may be taken over.
626    pub fn override_lock(mut self, yes: bool) -> Self {
627        self.override_lock = yes;
628        self
629    }
630}
631
632/// Request bundle for [`crate::ports::Planner::sync_plan`]: register or
633/// update the variant `variant` of the plan line `(project, name)`.
634#[derive(Debug, Clone)]
635pub struct SyncRequest {
636    pub project: String,
637    pub name: String,
638    pub variant: String,
639    pub graph: PlanGraph,
640    /// Where the graph was read from (relative to the project root), if it
641    /// came from a plan file. `None` for an inline graph.
642    pub source_path: Option<String>,
643    /// Hash of the source file's bytes. `None` for an inline graph, which is
644    /// then identified by its canonical graph hash.
645    pub content_hash: Option<String>,
646    /// Passed to the revision when the content changed: release live locks
647    /// of removed deliverables instead of refusing. Defaults to `false`.
648    pub force: bool,
649}
650
651impl SyncRequest {
652    pub fn new(
653        project: impl Into<String>,
654        name: impl Into<String>,
655        variant: impl Into<String>,
656        graph: PlanGraph,
657    ) -> Self {
658        Self {
659            project: project.into(),
660            name: name.into(),
661            variant: variant.into(),
662            graph,
663            source_path: None,
664            content_hash: None,
665            force: false,
666        }
667    }
668
669    /// Record the plan file the graph was read from.
670    pub fn with_source_path(mut self, path: impl Into<String>) -> Self {
671        self.source_path = Some(path.into());
672        self
673    }
674
675    /// Record the hash of the plan file's bytes.
676    pub fn with_content_hash(mut self, hash: impl Into<String>) -> Self {
677        self.content_hash = Some(hash.into());
678        self
679    }
680
681    /// Set whether a changed graph may force-release live locks.
682    pub fn force(mut self, yes: bool) -> Self {
683        self.force = yes;
684        self
685    }
686}
687
688/// Request bundle for [`crate::ports::Planner::revise_plan`].
689#[derive(Debug, Clone)]
690pub struct ReviseRequest {
691    pub plan_id: PlanId,
692    pub graph: PlanGraph,
693    /// Release live locks of removed (or claim-conflicting) deliverables
694    /// instead of refusing with `LOCK_HELD`. Defaults to `false`.
695    pub force: bool,
696}
697
698impl ReviseRequest {
699    pub fn new(plan_id: PlanId, graph: PlanGraph) -> Self {
700        Self {
701            plan_id,
702            graph,
703            force: false,
704        }
705    }
706
707    /// Set whether live locks may be force-released.
708    pub fn force(mut self, yes: bool) -> Self {
709        self.force = yes;
710        self
711    }
712}
713
714/// Request bundle for [`crate::ports::Planner::fork_plan`]: copy the head
715/// graph of the named variant `plan_id`, apply `edits`, and register the
716/// result as the new draft variant `variant` of the same plan line.
717#[derive(Debug, Clone)]
718pub struct ForkRequest {
719    pub plan_id: PlanId,
720    pub variant: String,
721    /// Applied in order with [`crate::edits::apply_edits`].
722    pub edits: Vec<crate::edits::GraphEdit>,
723    /// Where to write the new variant's plan file. Falls back to the
724    /// planner's own root; with neither, the variant is registered inline.
725    pub project_root: Option<crate::project::ProjectRoot>,
726}
727
728impl ForkRequest {
729    pub fn new(plan_id: PlanId, variant: impl Into<String>) -> Self {
730        Self {
731            plan_id,
732            variant: variant.into(),
733            edits: Vec::new(),
734            project_root: None,
735        }
736    }
737
738    /// Set the edits applied to the copied graph.
739    pub fn with_edits(mut self, edits: Vec<crate::edits::GraphEdit>) -> Self {
740        self.edits = edits;
741        self
742    }
743
744    /// Write the new variant's file under `root`.
745    pub fn with_project_root(mut self, root: crate::project::ProjectRoot) -> Self {
746        self.project_root = Some(root);
747        self
748    }
749}
750
751/// Request bundle for [`crate::ports::Planner::compare_plans`]. Exactly one
752/// of `plan_ids` (two or more plans, any variants or unnamed plans) or
753/// `plan` (`(project, name)`: every non-archived variant of that line) is
754/// given.
755#[derive(Debug, Clone)]
756pub struct ComparePlansRequest {
757    pub plan_ids: Option<Vec<PlanId>>,
758    pub plan: Option<(String, String)>,
759    pub request: crate::compare::CompareRequest,
760}
761
762impl ComparePlansRequest {
763    /// Compare the given plans, in this order.
764    pub fn by_ids(plan_ids: Vec<PlanId>, request: crate::compare::CompareRequest) -> Self {
765        Self {
766            plan_ids: Some(plan_ids),
767            plan: None,
768            request,
769        }
770    }
771
772    /// Compare every non-archived variant of the line `(project, name)`.
773    pub fn by_plan(
774        project: impl Into<String>,
775        name: impl Into<String>,
776        request: crate::compare::CompareRequest,
777    ) -> Self {
778        Self {
779            plan_ids: None,
780            plan: Some((project.into(), name.into())),
781            request,
782        }
783    }
784}
785
786/// One variant of a plan line, as reported by
787/// [`crate::ports::Planner::list_plans`].
788#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
789pub struct VariantSummary {
790    pub variant: String,
791    pub plan_id: PlanId,
792    /// True for the line's one executable variant.
793    pub selected: bool,
794    pub archived: bool,
795    pub head_revision: u32,
796    #[serde(default, skip_serializing_if = "Option::is_none")]
797    pub source_path: Option<String>,
798    /// Deliverables `Complete`.
799    pub complete: usize,
800    /// Deliverables in the head graph.
801    pub total: usize,
802    /// True when every deliverable is `Complete` (vacuously for none).
803    pub plan_complete: bool,
804    /// Head graph's CPM makespan in hours.
805    pub makespan: f32,
806}
807
808/// A named plan line and its variants, sorted by variant name.
809#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
810pub struct PlanLineSummary {
811    pub project: String,
812    pub name: String,
813    pub selected_variant: Option<String>,
814    pub archived: bool,
815    pub variants: Vec<VariantSummary>,
816}
817
818/// Result of [`crate::ports::Planner::sync_plan`].
819#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
820pub struct SyncOutcome {
821    pub plan_id: PlanId,
822    pub name: String,
823    pub variant: String,
824    /// The variant's head revision after the sync.
825    pub revision: u32,
826    /// True when this sync registered the variant.
827    pub created: bool,
828    /// True when this sync created the variant or a new revision.
829    pub changed: bool,
830    /// What the revision changed; `None` unless a new revision was made.
831    #[serde(default)]
832    pub diff: Option<crate::revise::RevisionDiff>,
833}
834
835/// Result of [`crate::ports::Planner::select_variant`]. Selection does not
836/// change any graph, so this is a dedicated summary rather than a
837/// [`crate::revise::RevisionDiff`].
838#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
839pub struct SelectOutcome {
840    pub plan_id: PlanId,
841    pub project: String,
842    pub name: String,
843    /// The variant now selected (the one `plan_id` belongs to).
844    pub variant: String,
845    /// The previously selected variant; `None` when the line had none.
846    pub previous: Option<String>,
847    /// False when `variant` was already selected (a no-op).
848    pub changed: bool,
849    /// Sorted ids whose `Complete` status was copied from the previously
850    /// selected variant (identical canonical definition in both).
851    pub carried: Vec<String>,
852    /// Sorted ids of the previous variant's live locks released by a forced
853    /// selection.
854    pub released_locks: Vec<String>,
855    /// A baseline taken automatically because the newly selected variant had
856    /// progress but no baseline (auto mode); absent otherwise.
857    #[serde(default, skip_serializing_if = "Option::is_none")]
858    pub baseline_taken: Option<BaselineTaken>,
859}
860
861/// Snapshot of a held lock. The Planner records one [`LockInfo`] per
862/// acquired deliverable and surfaces them in `Cohort::locks` and
863/// [`PlanStatus::locks_held`].
864#[derive(Debug, Clone, Serialize, Deserialize)]
865pub struct LockInfo {
866    pub plan_id: PlanId,
867    pub deliverable_id: String,
868    pub caller_id: CallerId,
869    pub acquired_at: DateTime<Utc>,
870    /// TTL deadline. After this instant, the lock is treated as expired
871    /// and the Planner is free to release it on any subsequent operation.
872    pub expires_at: DateTime<Utc>,
873}
874
875/// One row in a [`Cohort`]: a deliverable held under a single lock.
876///
877/// Pairing the deliverable with its lock structurally makes the
878/// invariant "the i-th deliverable is held under the i-th lock"
879/// unrepresentable as broken at the type level. Pre-F5 the same
880/// invariant lived in a doc comment + a runtime test assertion; a
881/// future refactor that pushed to one parallel Vec but not the other
882/// would silently violate it.
883#[derive(Debug, Clone, Serialize, Deserialize)]
884pub struct CohortRow {
885    pub deliverable: Deliverable,
886    pub lock: LockInfo,
887}
888
889/// Result of a successful [`crate::ports::Planner::acquire_cohort`]
890/// call.
891///
892/// SPEC §33 audit fixup (F5 INTERFACE_GAP-001) — the previous shape
893/// was two parallel vectors (`deliverables: Vec<Deliverable>` +
894/// `locks: Vec<LockInfo>`) with the index-pairing invariant carried
895/// only in docs. F5 tightens to `rows: Vec<CohortRow>` so the
896/// invariant is type-enforced.
897///
898/// The wire shape is preserved: `#[serde(into = "FlatCohort", try_from =
899/// "FlatCohort")]` projects to/from the historical two-array JSON so
900/// MCP clients see no breaking change. Deserialization is fallible
901/// (CMP-032): mismatched `deliverables`/`locks` lengths are a corrupt
902/// payload and are rejected rather than silently truncated.
903#[derive(Debug, Clone, Serialize, Deserialize)]
904#[serde(into = "FlatCohort", try_from = "FlatCohort")]
905pub struct Cohort {
906    pub plan_id: PlanId,
907    pub rows: Vec<CohortRow>,
908    /// Deliverables the acquire considered but did not lease, and why.
909    pub blocked: Vec<BlockedDeliverable>,
910    /// Paths in this cohort claimed in append mode by two or more
911    /// deliverables (in the cohort or against held locks).
912    pub shared_paths: Vec<PathBuf>,
913    /// A baseline taken automatically because this acquire started
914    /// execution on a plan that had none (auto mode); absent otherwise.
915    pub baseline_taken: Option<BaselineTaken>,
916}
917
918/// A deliverable the acquire considered but did not lease, and why.
919#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
920pub struct BlockedDeliverable {
921    pub id: String,
922    /// Stable code; the authoritative list is "MANUAL", "NOT_READY",
923    /// "LOCKED", "LAPSE_LIMIT", "FILE_CONFLICT" and "MAX_COUNT" (see the
924    /// server `instructions()` for when each applies).
925    pub code: String,
926    pub reason: String,
927}
928
929/// Error returned when a `FlatCohort` wire payload cannot be decoded into a
930/// [`Cohort`] — currently only the deliverables/locks length mismatch
931/// (CMP-032). Carries both lengths for triage and implements `Display` so it
932/// satisfies serde's `try_from` error bound.
933#[derive(Debug, Clone, PartialEq, Eq)]
934pub struct CohortDecodeError {
935    pub deliverables: usize,
936    pub locks: usize,
937}
938
939impl std::fmt::Display for CohortDecodeError {
940    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
941        write!(
942            f,
943            "COHORT_LENGTH_MISMATCH: deliverables ({}) and locks ({}) arrays \
944             must be the same length; each deliverable is held under exactly one lock",
945            self.deliverables, self.locks
946        )
947    }
948}
949
950impl std::error::Error for CohortDecodeError {}
951
952/// Wire-shape adapter for [`Cohort`] — keeps the historical
953/// `{plan_id, deliverables, locks}` JSON layout so the F5 in-Rust
954/// API tightening does NOT break MCP clients. NEVER referenced
955/// directly; only via the `From`/`Into` plumbing.
956#[derive(Debug, Clone, Serialize, Deserialize)]
957struct FlatCohort {
958    plan_id: PlanId,
959    deliverables: Vec<Deliverable>,
960    locks: Vec<LockInfo>,
961    #[serde(default)]
962    blocked: Vec<BlockedDeliverable>,
963    #[serde(default)]
964    shared_paths: Vec<PathBuf>,
965    #[serde(default, skip_serializing_if = "Option::is_none")]
966    baseline_taken: Option<BaselineTaken>,
967}
968
969impl From<Cohort> for FlatCohort {
970    fn from(cohort: Cohort) -> Self {
971        let mut deliverables = Vec::with_capacity(cohort.rows.len());
972        let mut locks = Vec::with_capacity(cohort.rows.len());
973        for row in cohort.rows {
974            deliverables.push(row.deliverable);
975            locks.push(row.lock);
976        }
977        FlatCohort {
978            plan_id: cohort.plan_id,
979            deliverables,
980            locks,
981            blocked: cohort.blocked,
982            shared_paths: cohort.shared_paths,
983            baseline_taken: cohort.baseline_taken,
984        }
985    }
986}
987
988impl TryFrom<FlatCohort> for Cohort {
989    type Error = CohortDecodeError;
990
991    fn try_from(flat: FlatCohort) -> Result<Self, Self::Error> {
992        // CMP-032 — mismatched lengths mean an unpaired deliverable or lock.
993        // Previously this truncated to the shorter array, silently dropping
994        // lock-grant entries (or deliverables). That hides a real corruption,
995        // so we now reject the payload outright.
996        if flat.deliverables.len() != flat.locks.len() {
997            return Err(CohortDecodeError {
998                deliverables: flat.deliverables.len(),
999                locks: flat.locks.len(),
1000            });
1001        }
1002        let rows = flat
1003            .deliverables
1004            .into_iter()
1005            .zip(flat.locks)
1006            .map(|(deliverable, lock)| CohortRow { deliverable, lock })
1007            .collect();
1008        Ok(Cohort {
1009            plan_id: flat.plan_id,
1010            rows,
1011            blocked: flat.blocked,
1012            shared_paths: flat.shared_paths,
1013            baseline_taken: flat.baseline_taken,
1014        })
1015    }
1016}
1017
1018/// Read-only snapshot of a plan's current state, returned by
1019/// [`crate::ports::Planner::status`].
1020#[derive(Debug, Clone, Serialize, Deserialize)]
1021pub struct PlanStatus {
1022    pub plan_id: PlanId,
1023    /// Per-deliverable `(id, status, attempt_count, failure_count,
1024    /// lapse_count)`. Order matches insertion order in the originally
1025    /// submitted [`PlanGraph::deliverables`] so callers can render a
1026    /// stable table. On the wire each row is a positional array —
1027    /// trailing elements were appended, so readers indexing `[0..=2]`
1028    /// keep working (backward-tolerant extension).
1029    ///
1030    /// - `attempt_count` — total leases handed out via
1031    ///   [`crate::ports::Planner::acquire_cohort`] (telemetry).
1032    /// - `failure_count` — explicit `mark_status(Failed)` outcomes; this
1033    ///   is the retry pressure against the failure circuit-break cap
1034    ///   ([`crate::planner::MAX_ATTEMPTS`]).
1035    /// - `lapse_count` — leases lost environmentally (TTL expiry, no
1036    ///   terminal mark); pressure against the lapse bound
1037    ///   ([`crate::planner::MAX_LAPSES`]), never the failure breaker.
1038    pub deliverables: Vec<(String, DeliverableStatus, u32, u32, u32)>,
1039    /// Ids on the longest dependency chain, in execution order. Always
1040    /// begins with `__start__` and ends with `__finish__` (synthetic
1041    /// endpoints; an empty plan is just those two).
1042    pub critical_path: Vec<String>,
1043    /// Scheduled length plus lags along `critical_path` (= `__finish__`
1044    /// earliest finish).
1045    pub critical_path_hours: f32,
1046    /// Every lock currently active across the plan.
1047    pub locks_held: Vec<LockInfo>,
1048    /// Every zero-float deliverable (synthetic endpoints excluded), sorted by `(es, id)`.
1049    #[serde(default)]
1050    pub critical_ids: Vec<String>,
1051    /// Per-deliverable CPM schedule, in graph insertion order.
1052    #[serde(default)]
1053    pub schedule: Vec<ScheduleRow>,
1054    /// Deliverables with status `Ready` and no live lock, sorted by
1055    /// `(latest_start, float, id)` ascending (smallest latest start first,
1056    /// i.e. longest remaining tail; same order as
1057    /// [`crate::ports::Planner::acquire_cohort`] via the shared `priority_key`);
1058    /// membership is a superset: acquire may still skip deliverables at the
1059    /// failure or lapse cap, manual deliverables, or whose files overlap a
1060    /// held lock.
1061    #[serde(default)]
1062    pub ready: Vec<String>,
1063    /// True when every deliverable is `Complete` (vacuously true for an
1064    /// empty plan).
1065    #[serde(default)]
1066    pub plan_complete: bool,
1067    /// One row per milestone deliverable, in graph order.
1068    #[serde(default)]
1069    pub milestones: Vec<MilestoneRow>,
1070    /// Plan line name of a named variant; `None` for an unnamed plan.
1071    #[serde(default)]
1072    pub name: Option<String>,
1073    /// Variant name of a named variant; `None` for an unnamed plan.
1074    #[serde(default)]
1075    pub variant: Option<String>,
1076    /// Whether this variant is its line's selected (executable) variant;
1077    /// `None` for an unnamed plan.
1078    #[serde(default)]
1079    pub selected: Option<bool>,
1080    /// Effective archived flag of this variant (its line is archived or the
1081    /// variant itself is); `None` for an unnamed plan. Archived variants
1082    /// stay readable through `status` and `ev`.
1083    #[serde(default)]
1084    pub archived: Option<bool>,
1085    /// Only when [`PlanStatus::archived`] is `Some(true)`: the name of the
1086    /// project's single active plan line, so a caller holding a stale id
1087    /// learns where the live work is. `None` when not archived, for an
1088    /// unnamed plan, or when the project has zero or several active lines.
1089    #[serde(default)]
1090    pub active_plan: Option<String>,
1091    /// `Some(true)` when the variant's plan file no longer matches what was
1092    /// last synced (by content hash), `Some(false)` when it matches. `None`
1093    /// (unknown) when the variant has no source file, the planner has no
1094    /// project root for the variant's project, or the file is unreadable.
1095    #[serde(default)]
1096    pub definition_drift: Option<bool>,
1097    /// The latest earned-value baseline of the plan, or `null` before the
1098    /// first baseline.
1099    #[serde(default)]
1100    pub baseline: Option<PlanBaseline>,
1101}
1102
1103/// A milestone's schedule summary, reported by [`PlanStatus::milestones`].
1104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1105pub struct MilestoneRow {
1106    pub id: String,
1107    /// Longest chain from `__start__` to this milestone (ends at `id`).
1108    pub critical_path: Vec<String>,
1109    /// The milestone's earliest finish, in hours from plan start.
1110    pub hours: f32,
1111    /// True once the milestone deliverable is `Complete`.
1112    pub complete: bool,
1113}
1114
1115/// Largest plan accepted by submit and the analysis tools. Bounds the
1116/// quadratic parts of validation, lint and leveling.
1117pub const MAX_DELIVERABLES: usize = 5000;
1118
1119/// Upper bound, in hours, for every effort, duration, lag and estimate
1120/// value (about 114 years). Keeps schedule arithmetic far from `f32`
1121/// overflow.
1122pub const MAX_HOURS: f32 = 1_000_000.0;
1123
1124/// Longest `evidence` note `mark_status` accepts, in characters.
1125pub const MAX_EVIDENCE_CHARS: usize = 2048;
1126
1127/// Most evidence entries one deliverable keeps; appending beyond it is
1128/// `INVALID_ACTUALS`.
1129pub const MAX_EVIDENCE_ENTRIES: usize = 100;
1130
1131/// Reserved id of the synthetic zero-effort source node in every plan's CPM.
1132pub const START_ID: &str = "__start__";
1133/// Reserved id of the synthetic zero-effort sink node in every plan's CPM.
1134pub const FINISH_ID: &str = "__finish__";
1135
1136/// The stored definition of a plan: the [`PlanGraph`] exactly as submitted,
1137/// returned by [`crate::ports::Planner::get_plan`].
1138#[derive(Debug, Clone, Serialize, Deserialize)]
1139pub struct PlanDefinition {
1140    pub plan_id: PlanId,
1141    pub graph: PlanGraph,
1142}
1143
1144/// One deliverable's CPM schedule, in hours from plan start.
1145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1146pub struct ScheduleRow {
1147    pub id: String,
1148    pub es: f32,
1149    pub ef: f32,
1150    pub ls: f32,
1151    pub lf: f32,
1152    pub float: f32,
1153    pub critical: bool,
1154    /// True for the synthetic [`START_ID`] / [`FINISH_ID`] endpoint rows.
1155    #[serde(default)]
1156    pub synthetic: bool,
1157}
1158
1159/// Errors returned by [`crate::ports::Planner`] methods.
1160///
1161/// Every variant carries enough context to log without further lookup. The
1162/// stable string token at the start of the `#[error(..)]` message doubles
1163/// as the wire-level error code surfaced by PA4's MCP server, so the
1164/// variant prefixes (`LOCK_HELD`, `LOCK_NOT_HELD`, etc.) MUST NOT change
1165/// without bumping the MCP server schema.
1166#[derive(Debug, Error)]
1167pub enum PlannerError {
1168    /// The requested deliverable is already locked by another caller. The
1169    /// `holder` field surfaces the conflicting caller for human triage.
1170    #[error("LOCK_HELD: deliverable {deliverable_id} in plan {plan_id} is locked by {holder}")]
1171    LockHeld {
1172        plan_id: String,
1173        deliverable_id: String,
1174        holder: String,
1175    },
1176
1177    /// The caller invoked an operation that requires holding a lock
1178    /// (`mark_status`, `heartbeat`) but the lock is held by someone else,
1179    /// or no lock exists at all.
1180    #[error("LOCK_NOT_HELD: caller {caller_id} does not hold lock on {deliverable_id}")]
1181    LockNotHeld {
1182        caller_id: String,
1183        deliverable_id: String,
1184    },
1185
1186    /// The lock the caller is referencing has passed its TTL. The Planner
1187    /// is free to reclaim the deliverable for another caller.
1188    #[error("LOCK_EXPIRED: lock on {deliverable_id} expired at {expired_at}")]
1189    LockExpired {
1190        deliverable_id: String,
1191        expired_at: DateTime<Utc>,
1192    },
1193
1194    /// `acquire_cohort` discovered that the candidate deliverable's
1195    /// `owned_files` overlap with files held by an existing lock. Surfaced
1196    /// as a distinct variant (rather than `LOCK_HELD`) because the
1197    /// conflict is at the *file* level, not the deliverable level.
1198    #[error(
1199        "OVERLAP_DETECTED: deliverable {deliverable_id} owns files {files:?} that overlap with \
1200         currently locked files"
1201    )]
1202    OverlapDetected {
1203        deliverable_id: String,
1204        files: Vec<PathBuf>,
1205    },
1206
1207    /// `acquire_cohort` cannot include the candidate because at least one
1208    /// of its prerequisites is not yet [`DeliverableStatus::Complete`].
1209    /// This is not a hard error for the whole call (other cohort members
1210    /// may still be returned); it surfaces when a caller explicitly
1211    /// requests an ineligible deliverable.
1212    #[error(
1213        "MISSING_PREREQUISITE: deliverable {deliverable_id} requires {prereq} which is not \
1214         Complete"
1215    )]
1216    MissingPrerequisite {
1217        deliverable_id: String,
1218        prereq: String,
1219    },
1220
1221    /// The plan id supplied to a lookup or mutation does not correspond to
1222    /// any submitted plan.
1223    #[error("PLAN_NOT_FOUND: {plan_id}")]
1224    PlanNotFound { plan_id: String },
1225
1226    /// The deliverable id supplied to a lookup or mutation does not
1227    /// correspond to any deliverable in the named plan.
1228    #[error("DELIVERABLE_NOT_FOUND: {deliverable_id} in plan {plan_id}")]
1229    DeliverableNotFound {
1230        plan_id: String,
1231        deliverable_id: String,
1232    },
1233
1234    /// A deliverable's lease has lapsed via TTL (no terminal mark — the
1235    /// driving process was killed or timed out) more times than the
1236    /// runaway bound allows. These are ENVIRONMENTAL losses, not
1237    /// implementation failures, so the deliverable is NOT auto-failed;
1238    /// instead `acquire_cohort` skips it and reports it in
1239    /// [`Cohort::blocked`] until an operator intervenes (fix the
1240    /// environment, then `force_release` with `reset_counters`). No longer
1241    /// returned by `acquire_cohort`; kept for wire compatibility.
1242    #[error(
1243        "LAPSE_LIMIT: deliverable {deliverable_id} lost {lapse_count} leases to environmental \
1244         lapses (TTL expiry with no terminal mark — killed/timed-out drivers, NOT implementation \
1245         failures; bound {max_lapses}); fix the environment, then mark_status the deliverable to \
1246         proceed; clear it with plan.force_release {{reset_counters: true}}"
1247    )]
1248    LapseLimit {
1249        deliverable_id: String,
1250        lapse_count: u32,
1251        max_lapses: u32,
1252    },
1253
1254    /// A deliverable cannot be completed without a lease (or accepted)
1255    /// while some of its prerequisites are not yet `Complete`.
1256    #[error(
1257        "PREREQUISITES_INCOMPLETE: {deliverable_id} in plan {plan_id} has incomplete \
1258         prerequisites [{}]",
1259        missing.join(", ")
1260    )]
1261    PrerequisitesIncomplete {
1262        plan_id: String,
1263        deliverable_id: String,
1264        missing: Vec<String>,
1265    },
1266
1267    /// The submitted graph fails a structural invariant: duplicate ids,
1268    /// unknown prerequisite reference, cycle, empty `owned_files`, etc.
1269    /// The `reason` is the precise failure message.
1270    #[error("INVALID_GRAPH: {reason}")]
1271    InvalidGraph { reason: String },
1272
1273    /// A project/plan-file path failed validation: not a valid slug, not of the
1274    /// form `.cpm-planner/plans/<name>/<variant>.json`, absolute, contains `..`,
1275    /// or resolves (e.g. via a symlink) outside the plans directory.
1276    #[error("INVALID_PATH: {reason}")]
1277    InvalidPath { reason: String },
1278
1279    /// `plan.schedule` was given no usable capacity (missing or zero) for
1280    /// one or more resources that scheduled work needs. `missing` is sorted.
1281    #[error("INVALID_CAPACITIES: no capacity for resources [{}]", missing.join(", "))]
1282    InvalidCapacities { missing: Vec<String> },
1283
1284    /// An execution operation (`acquire_cohort`, `heartbeat`, `mark_status`,
1285    /// `accept`, `force_release`) targeted a named plan variant that is not
1286    /// its line's selected variant. Read and analysis tools are never gated.
1287    #[error(
1288        "VARIANT_NOT_SELECTED: plan {plan_id} is variant '{variant}' of '{name}'; selected is \
1289         '{selected}'"
1290    )]
1291    VariantNotSelected {
1292        plan_id: String,
1293        name: String,
1294        variant: String,
1295        selected: String,
1296    },
1297
1298    /// An operation was refused because of archiving: archiving the selected
1299    /// variant alone, syncing into / selecting an archived variant or line,
1300    /// unarchiving a variant of an archived line, or an execution operation
1301    /// on any variant of an archived line.
1302    #[error("ARCHIVE_REFUSED: {reason}")]
1303    ArchiveRefused { reason: String },
1304
1305    /// [`crate::ports::Planner::resolve_plan`] with
1306    /// [`PlanRef::Active`] found no active line: every line
1307    /// of `project` is archived, complete, or has no live selected variant.
1308    #[error(
1309        "NO_ACTIVE_PLAN: project '{project}' has no active plan line (an unarchived line whose \
1310         selected variant is unarchived and not complete)"
1311    )]
1312    NoActivePlan { project: String },
1313
1314    /// [`crate::ports::Planner::resolve_plan`] with
1315    /// [`PlanRef::Active`] found more than one active line in
1316    /// `project`; `lines` holds every name, sorted.
1317    #[error(
1318        "AMBIGUOUS_ACTIVE_PLAN: project '{project}' has {n} active plan lines: {names}; pass \
1319         plan or plan_id",
1320        n = lines.len(),
1321        names = lines.join(", ")
1322    )]
1323    AmbiguousActivePlan { project: String, lines: Vec<String> },
1324
1325    /// `mark_status` progress fields failed validation: `earned_pct` above
1326    /// 100 or given with a status other than `in_progress`/`complete`,
1327    /// `actual_effort_hours` not finite or outside `0..=1000000`, or
1328    /// `evidence` longer than [`MAX_EVIDENCE_CHARS`] characters or beyond
1329    /// [`MAX_EVIDENCE_ENTRIES`] entries for the deliverable.
1330    #[error("INVALID_ACTUALS: {reason}")]
1331    InvalidActuals { reason: String },
1332
1333    /// A request carried a value that is well-typed but out of range for
1334    /// the plan it targets, e.g. a `plan.baseline` `backfill_from` in the
1335    /// future or before the plan's creation time.
1336    #[error("INVALID_VALUE: {reason}")]
1337    InvalidValue { reason: String },
1338
1339    /// An earned-value read or snapshot (`plan.ev`, `plan.snapshot`)
1340    /// targeted a plan that has no baseline yet; take one with
1341    /// `plan.baseline` first.
1342    #[error("NOT_BASELINED: plan {plan_id} has no baseline; take one with plan.baseline")]
1343    NotBaselined { plan_id: String },
1344
1345    /// The server runs in `refuse` baseline mode and an execution-starting
1346    /// call targeted a plan with no baseline yet; take one with
1347    /// `plan.baseline` first.
1348    #[error(
1349        "BASELINE_REQUIRED: plan {plan_id} has no earned-value baseline and \
1350         CPM_BASELINE_MODE=refuse; take one with plan.baseline"
1351    )]
1352    BaselineRequired { plan_id: String },
1353
1354    /// Catch-all for backend failures (DB unavailable, serialization
1355    /// errors against the persistence layer, etc.). Wraps the underlying
1356    /// `anyhow::Error` so the caller can introspect via `source()`.
1357    #[error("BACKEND_ERROR: {0}")]
1358    BackendError(#[source] anyhow::Error),
1359}
1360
1361#[cfg(test)]
1362mod tests {
1363    use super::*;
1364
1365    /// Wire-shape smoke test: constructing a `PlanGraph` with one
1366    /// deliverable and round-tripping it through `serde_json` must
1367    /// preserve every field. The MCP server in PA4 depends on this
1368    /// invariant.
1369    #[test]
1370    fn plan_graph_serde_roundtrip() -> Result<(), serde_json::Error> {
1371        let graph = PlanGraph {
1372            deliverables: vec![Deliverable {
1373                id: "d1".to_string(),
1374                owned_files: vec!["src/foo.rs".into(), "src/bar.rs".into()],
1375                prerequisites: vec!["d0".into()],
1376                estimated_effort_hours: Some(1.5),
1377                metadata: serde_json::json!({"description": "smoke test"}),
1378                duration_hours: None,
1379                estimate: None,
1380                milestone: false,
1381                earning_rule: None,
1382            }],
1383            max_chained_dispatch: Some(8),
1384        };
1385
1386        let json = serde_json::to_string(&graph)?;
1387        let back: PlanGraph = serde_json::from_str(&json)?;
1388
1389        assert_eq!(back.deliverables.len(), 1);
1390        let d = &back.deliverables[0];
1391        assert_eq!(d.id, "d1");
1392        assert_eq!(
1393            d.owned_files,
1394            vec![OwnedFile::from("src/foo.rs"), OwnedFile::from("src/bar.rs")]
1395        );
1396        assert_eq!(d.prerequisites, vec![Prerequisite::from("d0")]);
1397        assert_eq!(d.estimated_effort_hours, Some(1.5));
1398        assert_eq!(d.metadata, serde_json::json!({"description": "smoke test"}));
1399        assert_eq!(back.max_chained_dispatch, Some(8));
1400        Ok(())
1401    }
1402
1403    #[test]
1404    fn explicit_default_earning_rule_deserializes_as_absent() {
1405        let d: Deliverable = serde_json::from_value(serde_json::json!({
1406            "id": "a",
1407            "owned_files": [],
1408            "prerequisites": [],
1409            "earning_rule": "zero_hundred"
1410        }))
1411        .unwrap();
1412        assert_eq!(d.earning_rule, None);
1413    }
1414
1415    /// `DeliverableStatus` uses an internally-tagged enum representation
1416    /// so the wire form matches what PA4's MCP server publishes. Pin the
1417    /// shape with an explicit JSON check so an accidental derive change
1418    /// fails loudly.
1419    #[test]
1420    fn deliverable_status_failed_carries_reason() -> Result<(), serde_json::Error> {
1421        let status = DeliverableStatus::Failed {
1422            reason: "tests failed".to_string(),
1423        };
1424        let json = serde_json::to_value(&status)?;
1425        assert_eq!(json["status"], "failed");
1426        assert_eq!(json["reason"], "tests failed");
1427        let back: DeliverableStatus = serde_json::from_value(json)?;
1428        assert_eq!(back, status);
1429        Ok(())
1430    }
1431
1432    /// SPEC §33 audit fixup (F5 INTERFACE_GAP-001) — Cohort tightened
1433    /// from parallel vectors to `Vec<CohortRow>`, but the JSON wire
1434    /// shape MUST stay as `{plan_id, deliverables, locks}` so MCP
1435    /// clients see no breaking change. Pin both directions of the
1436    /// `serde(into/from)` adapter.
1437    #[test]
1438    fn cohort_wire_shape_preserves_two_array_layout() -> Result<(), serde_json::Error> {
1439        let plan_id = PlanId("plan_x".to_string());
1440        let now = chrono::Utc::now();
1441        let cohort = Cohort {
1442            plan_id: plan_id.clone(),
1443            blocked: vec![],
1444            shared_paths: vec![],
1445            rows: vec![
1446                CohortRow {
1447                    deliverable: Deliverable {
1448                        id: "d1".to_string(),
1449                        owned_files: vec!["a.rs".into()],
1450                        prerequisites: vec![],
1451                        estimated_effort_hours: Some(1.0),
1452                        metadata: serde_json::Value::Null,
1453                        duration_hours: None,
1454                        estimate: None,
1455                        milestone: false,
1456                        earning_rule: None,
1457                    },
1458                    lock: LockInfo {
1459                        plan_id: plan_id.clone(),
1460                        deliverable_id: "d1".to_string(),
1461                        caller_id: CallerId("c1".to_string()),
1462                        acquired_at: now,
1463                        expires_at: now + chrono::Duration::seconds(60),
1464                    },
1465                },
1466                CohortRow {
1467                    deliverable: Deliverable {
1468                        id: "d2".to_string(),
1469                        owned_files: vec!["b.rs".into()],
1470                        prerequisites: vec![],
1471                        estimated_effort_hours: Some(2.0),
1472                        metadata: serde_json::Value::Null,
1473                        duration_hours: None,
1474                        estimate: None,
1475                        milestone: false,
1476                        earning_rule: None,
1477                    },
1478                    lock: LockInfo {
1479                        plan_id: plan_id.clone(),
1480                        deliverable_id: "d2".to_string(),
1481                        caller_id: CallerId("c1".to_string()),
1482                        acquired_at: now,
1483                        expires_at: now + chrono::Duration::seconds(60),
1484                    },
1485                },
1486            ],
1487            baseline_taken: None,
1488        };
1489        let json = serde_json::to_value(&cohort)?;
1490        // Wire shape: top-level keys are plan_id + deliverables + locks
1491        // (NOT `rows`). MCP clients depending on the historical shape
1492        // continue to see it.
1493        assert!(json.get("deliverables").is_some());
1494        assert!(json.get("locks").is_some());
1495        assert!(json.get("rows").is_none());
1496        let deliverables = json["deliverables"].as_array().unwrap();
1497        let locks = json["locks"].as_array().unwrap();
1498        assert_eq!(deliverables.len(), 2);
1499        assert_eq!(locks.len(), 2);
1500        // Position-aligned pairing on the wire.
1501        assert_eq!(deliverables[0]["id"], "d1");
1502        assert_eq!(locks[0]["deliverable_id"], "d1");
1503
1504        // Round-trip back into the in-Rust row form.
1505        let back: Cohort = serde_json::from_value(json)?;
1506        assert_eq!(back.rows.len(), 2);
1507        assert_eq!(back.rows[0].deliverable.id, "d1");
1508        assert_eq!(back.rows[0].lock.deliverable_id, "d1");
1509        assert_eq!(back.rows[1].deliverable.id, "d2");
1510        assert_eq!(back.rows[1].lock.deliverable_id, "d2");
1511        Ok(())
1512    }
1513
1514    /// CMP-032 — a wire payload whose `deliverables` and `locks` arrays have
1515    /// mismatched lengths is corrupt (an unpaired lock-grant or deliverable).
1516    /// Deserialization MUST error rather than silently truncate to the shorter
1517    /// array and drop the unpaired entry.
1518    #[test]
1519    fn cohort_rejects_mismatched_deliverables_and_locks_lengths() {
1520        let now = chrono::Utc::now();
1521        // Two deliverables but only one lock — the historical truncating impl
1522        // would have dropped d2 silently.
1523        let wire = serde_json::json!({
1524            "plan_id": "plan_x",
1525            "deliverables": [
1526                {
1527                    "id": "d1",
1528                    "owned_files": ["a.rs"],
1529                    "prerequisites": [],
1530                    "estimated_effort_hours": 1.0,
1531                    "metadata": null
1532                },
1533                {
1534                    "id": "d2",
1535                    "owned_files": ["b.rs"],
1536                    "prerequisites": [],
1537                    "estimated_effort_hours": 2.0,
1538                    "metadata": null
1539                }
1540            ],
1541            "locks": [
1542                {
1543                    "plan_id": "plan_x",
1544                    "deliverable_id": "d1",
1545                    "caller_id": "c1",
1546                    "acquired_at": now,
1547                    "expires_at": now + chrono::Duration::seconds(60)
1548                }
1549            ]
1550        });
1551
1552        let err = serde_json::from_value::<Cohort>(wire).unwrap_err();
1553        assert!(
1554            err.to_string().contains("COHORT_LENGTH_MISMATCH"),
1555            "expected COHORT_LENGTH_MISMATCH, got: {err}"
1556        );
1557    }
1558}