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