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