Skip to main content

cpm_planner/
planner.rs

1//! `BasicCpmPlanner` — open-source [`Planner`] implementation.
2//!
3//! Bridges the wire model in [`crate::plan`] to the internal
4//! CPM kernel in [`crate::algorithm`], enforces the lock-aware semantics
5//! described on [`Planner`], and emits an audit lifecycle for every lock
6//! state transition.
7//!
8//! # Atomicity & persistence
9//!
10//! All state lives in a [`SqlitePlanStore`]. Every mutating method runs
11//! its entire body — load `PlanState`, apply the existing in-memory
12//! scheduling logic, write back — inside ONE `BEGIN IMMEDIATE` SQLite
13//! transaction. That is what makes "acquire N disjoint deliverables
14//! together" a single observable step, and because the write lock is
15//! database-level it holds across OS processes, not just tasks in this
16//! process: an MCP server and an external `orchestrate` CLI pointed at
17//! the same database can never double-acquire.
18//!
19//! Constructors without an explicit store ([`BasicCpmPlanner::new`],
20//! [`BasicCpmPlanner::with_audit`], [`BasicCpmPlanner::with_parts`]) use a
21//! private in-memory database — the historical ephemeral behaviour. Use
22//! [`BasicCpmPlanner::with_store`] with a file-backed
23//! [`SqlitePlanStore`] for durable, cross-process state.
24//!
25//! # Audit emission
26//!
27//! Audit events are buffered into a `Vec<AuditEvent>` while the
28//! transaction is open, then drained to the [`AuditSink`] AFTER commit.
29//! A slow sink therefore never holds up concurrent acquirers.
30
31use std::collections::{HashMap, HashSet};
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34use std::time::Duration;
35
36use crate::audit::{AuditEvent, AuditSink, NullAuditSink};
37use crate::earned_value::{BaselineMode, BaselineTaken};
38use crate::plan::{
39    AcceptRequest, AcquireRequest, BlockedDeliverable, CallerId, Cohort, CohortRow,
40    ComparePlansRequest, Deliverable, DeliverableStatus, FINISH_ID, FileMode, ForceReleaseRequest,
41    ForkRequest, HeartbeatRequest, LockInfo, MAX_DELIVERABLES, MarkStatusRequest, OwnedFile,
42    PlanDefinition, PlanGraph, PlanId, PlanLineSummary, PlanStatus, PlannerError, ReviseRequest,
43    START_ID, ScheduleRow, SelectOutcome, SyncOutcome, SyncRequest,
44};
45use crate::plan_store::SqlitePlanStore;
46use crate::portfolio::{Archived, Revised, Selected, Synced, VariantInfo};
47use crate::ports::Planner;
48use crate::project::ProjectRoot;
49use crate::revise::RevisionDiff;
50use async_trait::async_trait;
51use chrono::{DateTime, Utc};
52use execution_policy::classify::{FailureClass, RetryDecision};
53use serde_json::json;
54
55use crate::locks::{FileClaim, PlanState, add_file_claims, modes_conflict};
56
57mod ev;
58
59/// Default TTL applied to newly acquired locks. Five minutes is the
60/// open-source default called out in SPEC §33 PA3.
61pub const DEFAULT_TTL: Duration = Duration::from_secs(5 * 60);
62
63/// Default ceiling for a per-call lease TTL (`ttl_seconds`). Eight hours
64/// is generous enough for long-running work while still bounding a
65/// forgotten lease; override with `CPM_MAX_TTL_SECS` on the server or
66/// [`BasicCpmPlanner::with_max_ttl`] as a library.
67pub const DEFAULT_MAX_TTL: Duration = Duration::from_secs(8 * 60 * 60);
68
69/// Hard ceiling for the configurable maximum lease TTL (30 days).
70pub const MAX_TTL_CEILING: Duration = Duration::from_secs(30 * 24 * 60 * 60);
71
72/// Circuit-breaker: maximum number of times a deliverable may be
73/// EXPLICITLY marked failed (via [`Planner::mark_status`] with
74/// `status = Failed`) before the next [`Planner::acquire_cohort`]
75/// auto-fails it instead of re-leasing. Only real implementation
76/// attempts count: a driver got the lease, did the work, and reported
77/// failure. A lease lost environmentally (driver killed, harness
78/// timeout — the lock lapses via TTL with no terminal mark) is tracked
79/// separately as a lapse and NEVER burns a circuit-breaker life; see
80/// [`MAX_LAPSES`] for the runaway bound on those.
81///
82/// This is the retry budget for the DURABLE, cross-process deliverable lease
83/// (distinct from `execution_policy`'s in-process async retry): the decision to
84/// keep leasing vs. circuit-break is expressed through that crate's
85/// [`RetryDecision`] (`lease_retry_decision`), and a broken deliverable is
86/// classified [`FailureClass::Permanent`].
87pub const MAX_ATTEMPTS: u32 = 3;
88
89/// Runaway protection for ENVIRONMENTAL lease lapses (TTL expiry with no
90/// terminal mark). Deliberately generous — a lapse says nothing about the
91/// deliverable's buildability, so it must not trip the failure
92/// circuit-breaker — but an infinitely-crashing environment still cannot
93/// spin forever: once a deliverable's `lapse_count` reaches this bound,
94/// [`Planner::acquire_cohort`] refuses to re-lease it and surfaces
95/// [`PlannerError::LapseLimit`] (stable `LAPSE_LIMIT:` prefix) so an
96/// operator fixes the environment instead of the planner silently
97/// burning leases.
98pub const MAX_LAPSES: u32 = 10;
99
100/// Decide, from a deliverable's durable explicit-`failure_count`, whether to
101/// keep leasing it or circuit-break — expressed in `execution_policy`'s shared
102/// vocabulary. `Retry` while under [`MAX_ATTEMPTS`]; `Stop` (→ auto-fail,
103/// [`FailureClass::Permanent`]) once the budget is spent.
104fn lease_retry_decision(failure_count: u32) -> RetryDecision {
105    if failure_count >= MAX_ATTEMPTS {
106        RetryDecision::Stop
107    } else {
108        RetryDecision::Retry
109    }
110}
111
112/// Legacy flat fallback for missing effort estimates.
113///
114/// As of CMP-016 the planner no longer uses this: when a deliverable omits
115/// `estimated_effort_hours`, `crate::schedule::deliverable_to_task` asks an
116/// [`crate::estimator::EffortEstimator`] for a kind-aware estimate instead of substituting a
117/// flat one hour. The constant is retained as a documented reference value
118/// for callers that want the historical default.
119pub const DEFAULT_EFFORT_HOURS: f32 = 1.0;
120
121/// Pluggable clock. Tests inject a closure backed by a shared instant so
122/// TTL expiry is deterministic without `std::thread::sleep`.
123pub type ClockFn = Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>;
124
125/// Open-source CPM planner with file-aware locking.
126pub struct BasicCpmPlanner {
127    /// Durable state: plans, statuses, locks, and the submit-dedup map.
128    /// Also the atomicity mechanism — see the module docs.
129    store: SqlitePlanStore,
130    audit: Arc<dyn AuditSink>,
131    ttl: Duration,
132    max_ttl: Duration,
133    clock: ClockFn,
134    /// The project whose plan files this planner may read and write (drift
135    /// detection, fork). `None`: file features degrade to inline/unknown.
136    project_root: Option<ProjectRoot>,
137    /// How execution treats a plan with no earned-value baseline yet.
138    baseline_mode: BaselineMode,
139}
140
141impl BasicCpmPlanner {
142    /// Construct a planner with a real-clock and the
143    /// [`DEFAULT_TTL`]. Audit events are dropped on the floor (use
144    /// [`Self::with_audit`] if you need them retained).
145    pub fn new() -> Self {
146        Self::with_audit(Arc::new(NullAuditSink))
147    }
148
149    /// Construct a planner with the supplied audit sink and the default
150    /// TTL. The real `Utc::now` is used as the clock.
151    pub fn with_audit(audit: Arc<dyn AuditSink>) -> Self {
152        Self::with_parts(audit, DEFAULT_TTL, Arc::new(Utc::now))
153    }
154
155    /// Simulate a stored plan's graph read-only: nothing is written and no
156    /// audit events are emitted. See [`crate::simulate::simulate`].
157    pub async fn simulate_plan(
158        &self,
159        plan_id: &PlanId,
160        req: &crate::simulate::SimulateRequest,
161    ) -> Result<crate::simulate::SimulationResult, PlannerError> {
162        let graph = self.get_plan(plan_id).await?.graph;
163        crate::simulate::simulate(&graph, req)
164    }
165
166    /// Override the lock TTL. Useful for short-lived integration tests.
167    pub fn with_ttl(mut self, ttl: Duration) -> Self {
168        self.ttl = ttl;
169        self
170    }
171
172    /// Override the maximum lease TTL a caller may request via
173    /// `ttl_seconds`. Requested values above this are clamped down to it.
174    /// The value is itself capped at [`MAX_TTL_CEILING`] (30 days).
175    pub fn with_max_ttl(mut self, max_ttl: Duration) -> Self {
176        self.max_ttl = max_ttl.min(MAX_TTL_CEILING);
177        self
178    }
179
180    /// Override the clock. Intended for deterministic TTL tests; production
181    /// code should not call this.
182    pub fn with_clock(mut self, clock: ClockFn) -> Self {
183        self.clock = clock;
184        self
185    }
186
187    /// Full-parts constructor with an ephemeral in-memory store. Public
188    /// for clients that want explicit control over every field at once.
189    pub fn with_parts(audit: Arc<dyn AuditSink>, ttl: Duration, clock: ClockFn) -> Self {
190        let store = SqlitePlanStore::open_in_memory()
191            .expect("INVARIANT: opening a private in-memory sqlite database cannot fail");
192        Self::with_store_parts(store, audit, ttl, clock)
193    }
194
195    /// Construct a planner backed by the supplied (typically file-backed)
196    /// store, with the default TTL and real clock. This is the durable,
197    /// cross-process configuration used by the MCP server binary.
198    pub fn with_store(store: SqlitePlanStore, audit: Arc<dyn AuditSink>) -> Self {
199        Self::with_store_parts(store, audit, DEFAULT_TTL, Arc::new(Utc::now))
200    }
201
202    /// Full-parts constructor over an explicit store.
203    pub fn with_store_parts(
204        store: SqlitePlanStore,
205        audit: Arc<dyn AuditSink>,
206        ttl: Duration,
207        clock: ClockFn,
208    ) -> Self {
209        Self {
210            store,
211            audit,
212            ttl,
213            max_ttl: DEFAULT_MAX_TTL,
214            clock,
215            project_root: None,
216            baseline_mode: BaselineMode::Auto,
217        }
218    }
219
220    /// Set how execution treats a plan with no earned-value baseline yet:
221    /// [`BaselineMode::Auto`] (the default) takes the first baseline
222    /// automatically; [`BaselineMode::Refuse`] fails with
223    /// `BASELINE_REQUIRED` until `plan.baseline` is called.
224    pub fn with_baseline_mode(mut self, mode: BaselineMode) -> Self {
225        self.baseline_mode = mode;
226        self
227    }
228
229    /// Give the planner a project root. [`Planner::status`] then reports
230    /// `definition_drift` for that project's file-backed variants, and
231    /// [`Planner::fork_plan`] writes the new variant's plan file there when
232    /// the request names no root of its own.
233    pub fn with_project_root(mut self, root: ProjectRoot) -> Self {
234        self.project_root = Some(root);
235        self
236    }
237
238    /// The backing store, for in-crate tests that inspect tables directly.
239    #[cfg(test)]
240    pub(crate) fn store(&self) -> &SqlitePlanStore {
241        &self.store
242    }
243
244    /// The project root given with [`Self::with_project_root`], if any. The
245    /// MCP server reads its root from here (one source of truth).
246    pub fn project_root(&self) -> Option<&ProjectRoot> {
247        self.project_root.as_ref()
248    }
249
250    /// The `(plan id, label, head graph)` inputs of a comparison, read in one
251    /// store snapshot: `plan_ids` in the given order (an unnamed plan is
252    /// labelled by its id) or every non-archived variant of the line `plan`
253    /// (sorted by variant). Both or neither, duplicate ids, or a count
254    /// outside `2..=`[`crate::compare::MAX_COMPARE_VARIANTS`] is
255    /// `INVALID_GRAPH`; an unknown plan or line is `PLAN_NOT_FOUND`. Pair it
256    /// with the pure, CPU-bound [`crate::compare::compare`].
257    pub fn compare_inputs(
258        &self,
259        req: &ComparePlansRequest,
260    ) -> Result<Vec<(PlanId, String, PlanGraph)>, PlannerError> {
261        if let Some(ids) = &req.plan_ids {
262            let distinct: HashSet<&str> = ids.iter().map(|id| id.0.as_str()).collect();
263            if distinct.len() != ids.len() {
264                return Err(PlannerError::InvalidGraph {
265                    reason: "plan_ids must be distinct".to_string(),
266                });
267            }
268            crate::compare::check_variant_count(ids.len())?;
269        }
270        self.store.read_tx(|tx| {
271            let targets: Vec<(PlanId, Option<String>)> = match (&req.plan_ids, &req.plan) {
272                (Some(ids), None) => ids.iter().map(|id| (id.clone(), None)).collect(),
273                (None, Some((project, name))) => {
274                    crate::portfolio::live_variants(tx, project, name)?
275                        .into_iter()
276                        .map(|(variant, id)| (id, Some(variant)))
277                        .collect()
278                }
279                _ => {
280                    return Err(PlannerError::InvalidGraph {
281                        reason: "compare takes exactly one of plan_ids or plan".to_string(),
282                    });
283                }
284            };
285            crate::compare::check_variant_count(targets.len())?;
286            targets
287                .into_iter()
288                .map(|(id, label)| {
289                    let (_, graph) = crate::portfolio::revision_graph(tx, &id, None)?;
290                    let label = match label {
291                        Some(variant) => variant,
292                        None => crate::portfolio::variant_info(tx, &id)?
293                            .map_or_else(|| id.0.clone(), |info| info.variant),
294                    };
295                    Ok((id, label, graph))
296                })
297                .collect::<Result<Vec<_>, PlannerError>>()
298        })
299    }
300
301    /// Refuse an export over `file` that would clobber work (see
302    /// [`Planner::export_plan`]): another variant's tracked plan file, or
303    /// this variant's own file while it holds local edits that were never
304    /// synced.
305    fn check_export_target(
306        &self,
307        plan_id: &PlanId,
308        root: &ProjectRoot,
309        file: &crate::project::PlanFileRef,
310    ) -> Result<(), PlannerError> {
311        let project = root.project_key();
312        let trackers = self.store.read_tx(|tx| {
313            crate::portfolio::variants_tracking(tx, &project, &file.rel_path)?
314                .into_iter()
315                .map(|id| {
316                    let info = crate::portfolio::variant_info(tx, &id)?;
317                    let (_, head) = crate::portfolio::revision_graph(tx, &id, None)?;
318                    let recorded = crate::portfolio::revision_content_hashes(tx, &id)?;
319                    Ok((id, info, hash_graph(&head), recorded))
320                })
321                .collect::<Result<Vec<_>, PlannerError>>()
322        })?;
323        for (id, info, head_hash, mut recorded) in trackers {
324            let Some(info) = info else { continue };
325            if id != *plan_id {
326                return Err(PlannerError::InvalidPath {
327                    reason: format!(
328                        "{} is the tracked plan file of variant '{}' of '{}'; pass force to \
329                         overwrite it",
330                        file.rel_path, info.variant, info.name
331                    ),
332                });
333            }
334            if definition_drift(Some(root), &info, &head_hash) != Some(true) {
335                continue;
336            }
337            // Drifted: refuse only if the file holds content no revision of
338            // this variant recorded (local edits). A file that merely lags
339            // the head (e.g. after an inline revise) is safe to re-export.
340            recorded.extend(info.content_hash.clone());
341            recorded.insert(head_hash);
342            if has_local_edits(root, file, &recorded) {
343                return Err(PlannerError::InvalidPath {
344                    reason: format!(
345                        "{} has local edits that were never synced (definition drift); sync it \
346                         or pass force to overwrite it",
347                        file.rel_path
348                    ),
349                });
350            }
351        }
352        Ok(())
353    }
354
355    fn now(&self) -> DateTime<Utc> {
356        (self.clock)()
357    }
358
359    /// Effective lease TTL for one call: the caller-requested value (or
360    /// the planner default) clamped to the configured maximum.
361    fn effective_ttl(&self, requested: Option<Duration>) -> Duration {
362        requested.unwrap_or(self.ttl).min(self.max_ttl)
363    }
364
365    /// Record one audit event emitted outside the planner's own operations
366    /// (e.g. `plan.review`). Sink failures are logged, never returned.
367    pub async fn record_audit(&self, event: AuditEvent) {
368        self.flush_audit(vec![event]).await;
369    }
370
371    /// Flush buffered audit events. Called after the transaction commits so a
372    /// slow sink never blocks concurrent planner callers.
373    async fn flush_audit(&self, events: Vec<AuditEvent>) {
374        for ev in events {
375            // Audit failures are intentionally swallowed at this layer:
376            // the planner's invariant is "lock state stays consistent
377            // even if observability fails". A `tracing::warn!` documents
378            // the loss without aborting the caller's operation.
379            if let Err(err) = self.audit.record(ev).await {
380                tracing::warn!(error = %err, "audit sink failed to record planner event");
381            }
382        }
383    }
384}
385
386impl Default for BasicCpmPlanner {
387    fn default() -> Self {
388        Self::new()
389    }
390}
391
392/// Drift of a variant's plan file against its head graph (`head_hash`, the
393/// [`hash_graph`] of the head read in the same snapshot as `info`).
394///
395/// `None` (unknown) unless there is a root for the variant's project, the
396/// variant has a source path, and that file is readable within
397/// [`crate::project::MAX_PLAN_FILE_BYTES`]. Otherwise: `Some(false)` when the
398/// file's bytes hash to the stored content hash; else the file is parsed and
399/// `Some(file graph != head graph)` (canonically, so re-formatting is not
400/// drift); an unparseable file is `Some(true)`.
401fn definition_drift(
402    root: Option<&ProjectRoot>,
403    info: &VariantInfo,
404    head_hash: &str,
405) -> Option<bool> {
406    let root = root?;
407    if root.project_key() != info.project {
408        return None;
409    }
410    let rel = info.source_path.as_deref()?;
411    let file = root.resolve_plan_file(rel).ok()?;
412    let bytes = root.read_bytes(&file).ok()?;
413    if info.content_hash.as_deref() == Some(crate::project::content_hash(&bytes).as_str()) {
414        return Some(false);
415    }
416    match serde_json::from_slice::<PlanGraph>(&bytes) {
417        Ok(graph) => Some(hash_graph(&graph) != head_hash),
418        Err(_) => Some(true),
419    }
420}
421
422/// True when `file` exists and its content matches none of `recorded` (by
423/// byte hash or canonical graph hash). An unreadable file counts as none.
424fn has_local_edits(
425    root: &ProjectRoot,
426    file: &crate::project::PlanFileRef,
427    recorded: &HashSet<String>,
428) -> bool {
429    let Ok(bytes) = root.read_bytes(file) else {
430        return false;
431    };
432    if recorded.contains(&crate::project::content_hash(&bytes)) {
433        return false;
434    }
435    match serde_json::from_slice::<PlanGraph>(&bytes) {
436        Ok(graph) => !recorded.contains(&hash_graph(&graph)),
437        Err(_) => true,
438    }
439}
440
441// ---------------------------------------------------------------------------
442// Graph validation + hashing
443// ---------------------------------------------------------------------------
444
445/// Canonical, order-independent JSON form of one deliverable: prerequisites
446/// and owned files sorted so declaration order never matters. Shared by
447/// [`hash_graph`], plan revision's "did the definition change" test, and
448/// variant comparison diffs.
449pub(crate) fn canonical_deliverable(d: &Deliverable) -> serde_json::Value {
450    let mut prereqs: Vec<serde_json::Value> = d
451        .prerequisites
452        .iter()
453        .map(|p| {
454            json!({
455                "id": p.id(),
456                "consumes": p.consumes(),
457                "kind": p.kind(),
458                "lag_hours": p.lag_hours() + 0.0,
459            })
460        })
461        .collect();
462    // Full-key order (serialised form) so duplicate-id edges hash
463    // independently of submission order.
464    prereqs.sort_by_cached_key(ToString::to_string);
465    let mut files: Vec<serde_json::Value> = d
466        .owned_files
467        .iter()
468        .map(|f| json!({ "path": f.path().to_string_lossy(), "mode": f.mode() }))
469        .collect();
470    files.sort_by_cached_key(ToString::to_string);
471    let mut canonical = json!({
472        "id": d.id,
473        "owned_files": files,
474        "prerequisites": prereqs,
475        "estimated_effort_hours": d.estimated_effort_hours,
476        "duration_hours": d.duration_hours,
477        "estimate": d.estimate,
478        "metadata": d.metadata,
479        "milestone": d.milestone,
480    });
481    // Only when set to a non-default rule, so graphs without one keep the
482    // hash they had before the field existed (dedup and inline sync stay
483    // stable) and an explicit `zero_hundred` hashes like an absent rule.
484    if let Some(rule) = d
485        .earning_rule
486        .filter(|r| *r != crate::plan::EarningRule::ZeroHundred)
487    {
488        canonical["earning_rule"] = json!(rule);
489    }
490    canonical
491}
492
493/// Deterministic content hash of a [`PlanGraph`]. Same logical graph -> same
494/// hash regardless of the order `deliverables` were submitted in. This is
495/// what lets `submit_plan` be idempotent, and what tells `sync_plan` whether
496/// a variant's graph changed.
497pub(crate) fn hash_graph(graph: &PlanGraph) -> String {
498    // Build a normalised JSON form: deliverables sorted by id; each
499    // deliverable's prerequisites + owned_files sorted; metadata kept
500    // as-is (callers are responsible for its determinism).
501    let mut deliverables: Vec<_> = graph
502        .deliverables
503        .iter()
504        .map(canonical_deliverable)
505        .collect();
506    deliverables.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
507
508    let payload = json!({
509        "deliverables": deliverables,
510        "max_chained_dispatch": graph.max_chained_dispatch,
511    });
512
513    // Invariant: `payload` was built from a JSON object literal whose
514    // leaves are all owned `String`, primitive, or already-validated
515    // `serde_json::Value` payloads. Serialisation cannot fail for these
516    // inputs; the `expect` documents the invariant and aborts loudly if
517    // a future refactor breaks it.
518    let serialised = serde_json::to_vec(&payload)
519        .expect("INVARIANT: plan-graph hash payload is JSON-serialisable");
520    crate::skills::sha256_hex(&serialised)
521}
522
523/// Reject graphs that fail any structural invariant. Returns
524/// [`PlannerError::InvalidGraph`] with a precise `reason` on first failure.
525/// Run by submit and by every analysis entry point (`resource_schedule`,
526/// `monte_carlo`, `simulate`).
527pub(crate) fn validate_graph(graph: &PlanGraph) -> Result<(), PlannerError> {
528    if graph.deliverables.len() > MAX_DELIVERABLES {
529        return Err(PlannerError::InvalidGraph {
530            reason: format!(
531                "plan has {} deliverables; maximum is {MAX_DELIVERABLES}",
532                graph.deliverables.len()
533            ),
534        });
535    }
536    for d in &graph.deliverables {
537        if d.id == START_ID || d.id == FINISH_ID {
538            return Err(PlannerError::InvalidGraph {
539                reason: format!("deliverable id '{}' is reserved", d.id),
540            });
541        }
542    }
543
544    // Duplicate ids.
545    let mut seen_ids: HashSet<&str> = HashSet::new();
546    for d in &graph.deliverables {
547        if !seen_ids.insert(d.id.as_str()) {
548            return Err(PlannerError::InvalidGraph {
549                reason: format!("duplicate deliverable id '{}'", d.id),
550            });
551        }
552    }
553
554    // Effort, duration, estimate and lag hours: finite, within
555    // 0..=MAX_HOURS, and estimates ordered. Same messages as `plan.lint`.
556    if let Some(problem) = crate::graph::value_problems(graph).into_iter().next() {
557        return Err(PlannerError::InvalidGraph {
558            reason: problem.message,
559        });
560    }
561
562    // Prerequisite references resolve.
563    let id_set: HashSet<&str> = graph.deliverables.iter().map(|d| d.id.as_str()).collect();
564    for d in &graph.deliverables {
565        for p in crate::graph::prerequisite_ids(d) {
566            if !id_set.contains(p) {
567                return Err(PlannerError::InvalidGraph {
568                    reason: format!(
569                        "prerequisite '{p}' for deliverable '{}' does not exist",
570                        d.id
571                    ),
572                });
573            }
574        }
575    }
576
577    // Shared files: any pair of claimants where at least one claim is
578    // exclusive must be ordered by prerequisites, so one can never run
579    // while the other holds the file. Append/append needs no ordering.
580    let mut reach: Option<HashMap<String, HashSet<String>>> = None;
581    let mut claimants: HashMap<&Path, Vec<(&str, FileMode)>> = HashMap::new();
582    let mut path_order: Vec<&Path> = Vec::new();
583    for d in &graph.deliverables {
584        for f in &d.owned_files {
585            let entry = claimants.entry(f.path()).or_insert_with(|| {
586                path_order.push(f.path());
587                Vec::new()
588            });
589            entry.push((d.id.as_str(), f.mode()));
590        }
591    }
592    for path in path_order {
593        let list = &claimants[path];
594        for (i, &(x, xm)) in list.iter().enumerate() {
595            for &(y, ym) in &list[i + 1..] {
596                if x == y || (xm == FileMode::Append && ym == FileMode::Append) {
597                    continue;
598                }
599                let reach = reach.get_or_insert_with(|| crate::graph::reachability(graph));
600                let ordered = reach.get(x).is_some_and(|r| r.contains(y))
601                    || reach.get(y).is_some_and(|r| r.contains(x));
602                if !ordered {
603                    return Err(PlannerError::InvalidGraph {
604                        reason: format!(
605                            "file '{}' is claimed by '{x}' and '{y}', which are not ordered by prerequisites (one could run while the other holds it)",
606                            path.display(),
607                        ),
608                    });
609                }
610            }
611        }
612    }
613
614    // Cycle detection via Kahn's algorithm on the prerequisite DAG.
615    let mut indeg: HashMap<&str, usize> = HashMap::new();
616    let mut succs: HashMap<&str, Vec<&str>> = HashMap::new();
617    for d in &graph.deliverables {
618        indeg.entry(d.id.as_str()).or_insert(0);
619        succs.entry(d.id.as_str()).or_default();
620    }
621    for d in &graph.deliverables {
622        for p in crate::graph::prerequisite_ids(d) {
623            *indeg.entry(d.id.as_str()).or_insert(0) += 1;
624            succs.entry(p).or_default().push(d.id.as_str());
625        }
626    }
627    let mut queue: Vec<&str> = indeg
628        .iter()
629        .filter_map(|(k, v)| if *v == 0 { Some(*k) } else { None })
630        .collect();
631    let mut popped = 0_usize;
632    while let Some(node) = queue.pop() {
633        popped += 1;
634        if let Some(s) = succs.get(node).cloned() {
635            for next in s {
636                if let Some(deg) = indeg.get_mut(next) {
637                    *deg -= 1;
638                    if *deg == 0 {
639                        queue.push(next);
640                    }
641                }
642            }
643        }
644    }
645    if popped < graph.deliverables.len() {
646        // SPEC §33 audit fixup (F6 STUB-007) — name the cycle members.
647        // After Kahn's terminates, any node with residual in-degree > 0
648        // is part of (or downstream of) at least one cycle. Listing
649        // them sorted gives operators a starting set to debug from
650        // instead of "somewhere in your 50-deliverable graph there's
651        // a cycle, good luck."
652        let mut cycle_members: Vec<&str> = indeg
653            .iter()
654            .filter_map(|(k, v)| if *v > 0 { Some(*k) } else { None })
655            .collect();
656        cycle_members.sort_unstable();
657        return Err(PlannerError::InvalidGraph {
658            reason: format!(
659                "cycle detected in prerequisite graph involving deliverables: [{}]",
660                cycle_members.join(", ")
661            ),
662        });
663    }
664
665    Ok(())
666}
667
668/// Build a new plan from a validated graph: CPM result, a fresh id, and
669/// initial statuses (no prerequisites -> Ready, else Pending).
670fn initial_plan(graph: PlanGraph) -> Result<(PlanId, PlanState), PlannerError> {
671    let cached_result = crate::schedule::compute_cpm(&graph)?;
672    let mut statuses: HashMap<String, DeliverableStatus> =
673        HashMap::with_capacity(graph.deliverables.len());
674    for d in &graph.deliverables {
675        let status = if d.prerequisites.is_empty() {
676            DeliverableStatus::Ready
677        } else {
678            DeliverableStatus::Pending
679        };
680        statuses.insert(d.id.clone(), status);
681    }
682    let plan_id = PlanId(format!("plan_{}", uuid::Uuid::new_v4().simple()));
683    Ok((plan_id, PlanState::new(graph, statuses, cached_result)))
684}
685
686// ---------------------------------------------------------------------------
687// Audit helpers
688// ---------------------------------------------------------------------------
689
690fn make_portfolio_created_event(
691    outcome: &SyncOutcome,
692    project: &str,
693    selected: bool,
694) -> AuditEvent {
695    AuditEvent::new("plan.portfolio.created").with_payload(json!({
696        "plan_id": outcome.plan_id.as_str(),
697        "project": project,
698        "name": outcome.name,
699        "variant": outcome.variant,
700        "revision": outcome.revision,
701        "selected": selected,
702    }))
703}
704
705fn make_portfolio_revised_event(
706    plan_id: &PlanId,
707    revision: u32,
708    diff: &RevisionDiff,
709) -> AuditEvent {
710    AuditEvent::new("plan.portfolio.revised").with_payload(json!({
711        "plan_id": plan_id.as_str(),
712        "revision": revision,
713        "diff": diff,
714    }))
715}
716
717/// Audit trail of a committed revision: reaped (expired) locks, locks the
718/// forced revision released, then the revision itself.
719fn revision_events(plan_id: &PlanId, revised: &Revised, now: DateTime<Utc>) -> Vec<AuditEvent> {
720    if revised.no_op {
721        return Vec::new();
722    }
723    let mut events: Vec<AuditEvent> = revised
724        .reaped
725        .iter()
726        .map(|lock| make_expired_event(lock, now))
727        .collect();
728    events.extend(
729        revised
730            .released
731            .iter()
732            .map(|lock| make_released_event(lock, "revision")),
733    );
734    events.push(make_portfolio_revised_event(
735        plan_id,
736        revised.revision,
737        &revised.diff,
738    ));
739    if let Some(count) = revised.completed {
740        events.push(make_plan_completed_event(plan_id, count));
741    }
742    events
743}
744
745fn make_portfolio_selected_event(outcome: &SelectOutcome) -> AuditEvent {
746    AuditEvent::new("plan.portfolio.selected").with_payload(json!({
747        "plan_id": outcome.plan_id.as_str(),
748        "project": outcome.project,
749        "name": outcome.name,
750        "from": outcome.previous,
751        "to": outcome.variant,
752        "carried": outcome.carried,
753        "released": outcome.released_locks,
754    }))
755}
756
757/// Audit trail of a committed selection: reaped (expired) locks, locks the
758/// forced selection released, the selection itself, then `plan.completed`
759/// when the carry-over completed the newly selected plan. A no-op selection
760/// is not audited.
761fn selection_events(selected: &Selected, now: DateTime<Utc>) -> Vec<AuditEvent> {
762    if !selected.outcome.changed {
763        return Vec::new();
764    }
765    let mut events: Vec<AuditEvent> = selected
766        .reaped
767        .iter()
768        .map(|lock| make_expired_event(lock, now))
769        .collect();
770    events.extend(
771        selected
772            .released
773            .iter()
774            .map(|lock| make_released_event(lock, "variant deselected")),
775    );
776    events.push(make_portfolio_selected_event(&selected.outcome));
777    if let Some(count) = selected.completed {
778        events.push(make_plan_completed_event(&selected.outcome.plan_id, count));
779    }
780    events
781}
782
783/// Audit trail of a committed archive (`archived`) or unarchive: reaped
784/// (expired) locks, locks a forced line archive released, then
785/// `plan.portfolio.archived` / `plan.portfolio.unarchived`. Nothing when no
786/// flag changed.
787fn archive_events(
788    project: &str,
789    name: &str,
790    variant: Option<&str>,
791    archived: bool,
792    outcome: &Archived,
793    now: DateTime<Utc>,
794) -> Vec<AuditEvent> {
795    if !outcome.changed() {
796        return Vec::new();
797    }
798    let mut events: Vec<AuditEvent> = outcome
799        .reaped
800        .iter()
801        .map(|lock| make_expired_event(lock, now))
802        .collect();
803    events.extend(
804        outcome
805            .released
806            .iter()
807            .map(|lock| make_released_event(lock, "line archived")),
808    );
809    let variants: Vec<serde_json::Value> = outcome
810        .variants
811        .iter()
812        .map(|(v, plan_id)| json!({ "variant": v, "plan_id": plan_id.as_str() }))
813        .collect();
814    let event_type = if archived {
815        "plan.portfolio.archived"
816    } else {
817        "plan.portfolio.unarchived"
818    };
819    events.push(AuditEvent::new(event_type).with_payload(json!({
820        "project": project,
821        "name": name,
822        "variant": variant,
823        "line": outcome.line,
824        "variants": variants,
825        "released": outcome
826            .released
827            .iter()
828            .map(|l| l.deliverable_id.as_str())
829            .collect::<Vec<_>>(),
830    })));
831    events
832}
833
834/// The [`SyncOutcome`] of a committed sync and its audit trail.
835fn sync_outcome(
836    synced: Synced,
837    project: &str,
838    name: String,
839    variant: String,
840    now: DateTime<Utc>,
841) -> (SyncOutcome, Vec<AuditEvent>) {
842    let outcome = |plan_id, revision, created, changed, diff| SyncOutcome {
843        plan_id,
844        name: name.clone(),
845        variant: variant.clone(),
846        revision,
847        created,
848        changed,
849        diff,
850    };
851    match synced {
852        Synced::Created { plan_id, selected } => {
853            let out = outcome(plan_id, 1, true, true, None);
854            let event = make_portfolio_created_event(&out, project, selected);
855            (out, vec![event])
856        }
857        Synced::Unchanged { plan_id, revision } => {
858            (outcome(plan_id, revision, false, false, None), Vec::new())
859        }
860        Synced::Revised { plan_id, revised } => {
861            let events = revision_events(&plan_id, &revised, now);
862            let out = outcome(plan_id, revised.revision, false, true, Some(revised.diff));
863            (out, events)
864        }
865    }
866}
867
868/// Longest `project` key accepted by `sync_plan` (a path-derived key, not a
869/// slug).
870const MAX_PROJECT_LEN: usize = 512;
871
872/// Characters refused in a project key: controls (newlines included) and
873/// invisible Unicode format/separator characters that can disguise how the
874/// key reads when echoed (zero-width, bidi embeddings/overrides/isolates,
875/// word joiners, BOM, line/paragraph separators, interlinear annotations).
876fn is_disallowed_project_char(c: char) -> bool {
877    c.is_control()
878        || matches!(
879            c,
880            '\u{200B}'..='\u{200F}'
881                | '\u{202A}'..='\u{202E}'
882                | '\u{2060}'..='\u{206F}'
883                | '\u{FEFF}'
884                | '\u{2028}'
885                | '\u{2029}'
886                | '\u{FFF9}'..='\u{FFFB}'
887        )
888}
889
890/// Reject a malformed `(project, name, variant)` with `INVALID_PATH`. The
891/// project key is echoed by tools and audit events, so control and
892/// invisible format characters are refused.
893fn validate_variant_key(req: &SyncRequest) -> Result<(), PlannerError> {
894    if req.project.is_empty() || req.project.chars().count() > MAX_PROJECT_LEN {
895        return Err(PlannerError::InvalidPath {
896            reason: format!("project must be 1..={MAX_PROJECT_LEN} characters"),
897        });
898    }
899    if req.project.chars().any(is_disallowed_project_char) {
900        return Err(PlannerError::InvalidPath {
901            reason: "project must not contain control or invisible format characters".to_string(),
902        });
903    }
904    crate::project::validate_slug("name", &req.name)?;
905    crate::project::validate_slug("variant", &req.variant)
906}
907
908fn make_acquired_event(lock: &LockInfo, owned_files: &[OwnedFile]) -> AuditEvent {
909    AuditEvent::new("plan.lock.acquired")
910        .with_actor(lock.caller_id.as_str())
911        .with_payload(json!({
912            "plan_id": lock.plan_id.as_str(),
913            "deliverable_id": lock.deliverable_id,
914            "caller_id": lock.caller_id.as_str(),
915            "acquired_at": lock.acquired_at,
916            "expires_at": lock.expires_at,
917            "owned_files": owned_files,
918        }))
919}
920
921fn make_released_event(lock: &LockInfo, reason: &str) -> AuditEvent {
922    AuditEvent::new("plan.lock.released")
923        .with_actor(lock.caller_id.as_str())
924        .with_payload(json!({
925            "plan_id": lock.plan_id.as_str(),
926            "deliverable_id": lock.deliverable_id,
927            "caller_id": lock.caller_id.as_str(),
928            "reason": reason,
929        }))
930}
931
932fn make_expired_event(lock: &LockInfo, expired_at: DateTime<Utc>) -> AuditEvent {
933    AuditEvent::new("plan.lock.expired")
934        .with_actor(lock.caller_id.as_str())
935        .with_payload(json!({
936            "plan_id": lock.plan_id.as_str(),
937            "deliverable_id": lock.deliverable_id,
938            "last_caller_id": lock.caller_id.as_str(),
939            "expired_at": expired_at,
940        }))
941}
942
943fn make_circuit_break_event(
944    plan_id: &PlanId,
945    deliverable_id: &str,
946    failure_count: u32,
947    class: FailureClass,
948    reason: &str,
949) -> AuditEvent {
950    AuditEvent::new("plan.deliverable.circuit_broken").with_payload(json!({
951        "plan_id": plan_id.as_str(),
952        "deliverable_id": deliverable_id,
953        "failure_count": failure_count,
954        "max_attempts": MAX_ATTEMPTS,
955        "failure_class": format!("{class:?}"),
956        "retry_decision": format!("{:?}", RetryDecision::Stop),
957        "reason": reason,
958    }))
959}
960
961/// Validate `mark_status` progress fields (`INVALID_ACTUALS`). Takes wide
962/// types (any JSON number) so the server can check raw wire values with the
963/// same messages: a negative, fractional or > 100 `earned_pct` is refused.
964pub(crate) fn validate_actuals(
965    status: &DeliverableStatus,
966    earned_pct: Option<f64>,
967    actual_effort_hours: Option<f64>,
968    evidence: Option<&str>,
969) -> Result<(), PlannerError> {
970    let bad = |reason: String| Err(PlannerError::InvalidActuals { reason });
971    if let Some(pct) = earned_pct {
972        if !(pct.fract() == 0.0 && (0.0..=100.0).contains(&pct)) {
973            return bad(format!("earned_pct must be an integer 0..=100, got {pct}"));
974        }
975        if !matches!(
976            status,
977            DeliverableStatus::InProgress | DeliverableStatus::Complete
978        ) {
979            return bad(
980                "earned_pct is only accepted with status in_progress (or complete, where it is \
981                 ignored)"
982                    .to_string(),
983            );
984        }
985    }
986    if let Some(h) = actual_effort_hours
987        && !(h.is_finite() && (0.0..=f64::from(crate::plan::MAX_HOURS)).contains(&h))
988    {
989        return bad(format!(
990            "actual_effort_hours must be a finite number between 0 and 1000000, got {h}"
991        ));
992    }
993    if let Some(e) = evidence {
994        let chars = e.chars().count();
995        if chars > crate::plan::MAX_EVIDENCE_CHARS {
996            return bad(format!(
997                "evidence must be at most {} characters, got {chars}",
998                crate::plan::MAX_EVIDENCE_CHARS
999            ));
1000        }
1001    }
1002    Ok(())
1003}
1004
1005/// Missing (non-Complete) prerequisites of `deliverable_id`.
1006fn incomplete_prerequisites(state: &PlanState, deliverable_id: &str) -> Vec<String> {
1007    state
1008        .graph
1009        .deliverables
1010        .iter()
1011        .find(|d| d.id == deliverable_id)
1012        .map(|d| {
1013            crate::graph::prerequisite_ids(d)
1014                .filter(|p| !matches!(state.statuses.get(*p), Some(DeliverableStatus::Complete)))
1015                .map(str::to_string)
1016                .collect()
1017        })
1018        .unwrap_or_default()
1019}
1020
1021/// Mark a deliverable `Complete`: end any lease at `now` (dropping its file
1022/// claims, crediting its hours, emitting a released event), set the status, and promote dependents whose
1023/// prerequisites are now all complete. Shared by `mark_status` and `accept`.
1024///
1025/// Returns true when this completion made every deliverable `Complete`
1026/// (the false -> true flip of `plan_complete`), so the caller can emit
1027/// `plan.completed` exactly once.
1028fn complete_deliverable(
1029    state: &mut PlanState,
1030    deliverable_id: &str,
1031    audit_buf: &mut Vec<AuditEvent>,
1032    release_reason: &str,
1033    now: DateTime<Utc>,
1034) -> bool {
1035    let was_complete = all_complete(state);
1036    if let Some(lock) = state.end_lease(deliverable_id, now) {
1037        audit_buf.push(make_released_event(&lock, release_reason));
1038    }
1039
1040    state
1041        .statuses
1042        .insert(deliverable_id.to_string(), DeliverableStatus::Complete);
1043
1044    let dependents: Vec<String> = state
1045        .graph
1046        .deliverables
1047        .iter()
1048        .filter(|d| crate::graph::prerequisite_ids(d).any(|p| p == deliverable_id))
1049        .map(|d| d.id.clone())
1050        .collect();
1051    for dep_id in dependents {
1052        let dep = match state.graph.deliverables.iter().find(|d| d.id == dep_id) {
1053            Some(d) => d,
1054            None => unreachable!("dependent id {dep_id} present in graph but not findable"),
1055        };
1056        let all_done = crate::graph::prerequisite_ids(dep)
1057            .all(|p| matches!(state.statuses.get(p), Some(DeliverableStatus::Complete)));
1058        let currently_pending = matches!(
1059            state.statuses.get(&dep_id),
1060            Some(DeliverableStatus::Pending)
1061        );
1062        if all_done && currently_pending {
1063            state.statuses.insert(dep_id, DeliverableStatus::Ready);
1064        }
1065    }
1066    !was_complete && all_complete(state)
1067}
1068
1069/// True when every deliverable in the plan is `Complete`.
1070pub(crate) fn all_complete(state: &PlanState) -> bool {
1071    state
1072        .graph
1073        .deliverables
1074        .iter()
1075        .all(|d| matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Complete)))
1076}
1077
1078fn make_plan_completed_event(plan_id: &PlanId, deliverable_count: usize) -> AuditEvent {
1079    AuditEvent::new("plan.completed").with_payload(json!({
1080        "plan_id": plan_id.as_str(),
1081        "deliverable_count": deliverable_count,
1082    }))
1083}
1084
1085fn make_accepted_event(
1086    req: &AcceptRequest,
1087    overrode_lock_of: Option<&str>,
1088    previous_status: &DeliverableStatus,
1089) -> AuditEvent {
1090    AuditEvent::new("plan.deliverable.accepted")
1091        .with_actor(req.accepted_by.as_str())
1092        .with_payload(json!({
1093            "plan_id": req.plan_id.as_str(),
1094            "deliverable_id": req.deliverable_id,
1095            "accepted_by": req.accepted_by,
1096            "evidence": req.evidence,
1097            "overrode_lock_of": overrode_lock_of,
1098            "previous_status": previous_status,
1099        }))
1100}
1101
1102fn make_completed_without_lease_event(
1103    plan_id: &PlanId,
1104    deliverable_id: &str,
1105    caller_id: &CallerId,
1106    previous_status: &DeliverableStatus,
1107) -> AuditEvent {
1108    AuditEvent::new("plan.deliverable.completed_without_lease")
1109        .with_actor(caller_id.as_str())
1110        .with_payload(json!({
1111            "plan_id": plan_id.as_str(),
1112            "deliverable_id": deliverable_id,
1113            "caller_id": caller_id.as_str(),
1114            "previous_status": previous_status,
1115        }))
1116}
1117
1118fn make_marked_without_lease_event(
1119    plan_id: &PlanId,
1120    deliverable_id: &str,
1121    caller_id: &CallerId,
1122    status: &DeliverableStatus,
1123    previous_status: &DeliverableStatus,
1124) -> AuditEvent {
1125    AuditEvent::new("plan.deliverable.marked_without_lease")
1126        .with_actor(caller_id.as_str())
1127        .with_payload(json!({
1128            "plan_id": plan_id.as_str(),
1129            "deliverable_id": deliverable_id,
1130            "caller_id": caller_id.as_str(),
1131            "status": status,
1132            "previous_status": previous_status,
1133        }))
1134}
1135
1136fn make_force_released_event(lock: &LockInfo, reason: &str) -> AuditEvent {
1137    AuditEvent::new("plan.lock.force_released")
1138        .with_actor(lock.caller_id.as_str())
1139        .with_payload(json!({
1140            "plan_id": lock.plan_id.as_str(),
1141            "deliverable_id": lock.deliverable_id,
1142            "last_caller_id": lock.caller_id.as_str(),
1143            "reason": reason,
1144        }))
1145}
1146
1147fn make_counters_reset_event(
1148    plan_id: &PlanId,
1149    deliverable_id: &str,
1150    reason: &str,
1151    lapse_count: u32,
1152    failure_count: u32,
1153) -> AuditEvent {
1154    AuditEvent::new("plan.deliverable.counters_reset")
1155        .with_actor("operator")
1156        .with_payload(json!({
1157            "plan_id": plan_id.as_str(),
1158            "deliverable_id": deliverable_id,
1159            "reason": reason,
1160            "previous": { "lapse_count": lapse_count, "failure_count": failure_count },
1161        }))
1162}
1163
1164// ---------------------------------------------------------------------------
1165// Priority ordering for cohort selection
1166// ---------------------------------------------------------------------------
1167
1168/// Sort key for the ready-set priority pass: smallest latest start first
1169/// (longest remaining tail leads), then least total float, then id for
1170/// determinism.
1171fn priority_key(
1172    deliverable_id: &str,
1173    sched_by_id: &HashMap<&str, (f32, f32)>,
1174) -> (i64, i64, String) {
1175    let (latest_start, float) = match sched_by_id.get(deliverable_id) {
1176        Some(&v) => v,
1177        None => unreachable!(
1178            "deliverable '{deliverable_id}' is in the ready set but absent from the cached \
1179             CPM schedule — ready set and CPM result are out of sync"
1180        ),
1181    };
1182    let scale = |h: f32| (h * 1000.0).round() as i64;
1183    (
1184        scale(latest_start),
1185        scale(float),
1186        deliverable_id.to_string(),
1187    )
1188}
1189
1190/// Sort key for the `plan.status` ready display: smallest total float first
1191/// (most critical ready work leads), then smallest latest start, then id for
1192/// determinism. This deliberately differs from [`priority_key`], which drives
1193/// cohort leasing by longest remaining tail.
1194fn ready_display_key(
1195    deliverable_id: &str,
1196    sched_by_id: &HashMap<&str, (f32, f32)>,
1197) -> (i64, i64, String) {
1198    let (latest_start, float) = match sched_by_id.get(deliverable_id) {
1199        Some(&v) => v,
1200        None => unreachable!(
1201            "deliverable '{deliverable_id}' is in the ready set but absent from the cached \
1202             CPM schedule — ready set and CPM result are out of sync"
1203        ),
1204    };
1205    let scale = |h: f32| (h * 1000.0).round() as i64;
1206    (
1207        scale(float),
1208        scale(latest_start),
1209        deliverable_id.to_string(),
1210    )
1211}
1212
1213// ---------------------------------------------------------------------------
1214// Planner impl
1215// ---------------------------------------------------------------------------
1216
1217#[async_trait]
1218impl Planner for BasicCpmPlanner {
1219    async fn submit_plan(&self, graph: PlanGraph) -> Result<PlanId, PlannerError> {
1220        validate_graph(&graph)?;
1221        let graph_hash = hash_graph(&graph);
1222
1223        // The dedup check + build + insert all happen inside ONE immediate
1224        // sqlite transaction, so identical concurrent submissions — even
1225        // from different processes — resolve to a single PlanId. The build
1226        // closure only runs on a dedup miss.
1227        self.store
1228            .submit_or_get(&graph_hash, move || initial_plan(graph))
1229    }
1230
1231    async fn sync_plan(&self, req: SyncRequest) -> Result<SyncOutcome, PlannerError> {
1232        validate_variant_key(&req)?;
1233        validate_graph(&req.graph)?;
1234        let graph_hash = hash_graph(&req.graph);
1235        let now = self.now();
1236        let (project, name, variant) = (req.project.clone(), req.name.clone(), req.variant.clone());
1237        // Named plans bypass the global submit dedup: create, compare and
1238        // revise all happen in one immediate transaction.
1239        let synced = self
1240            .store
1241            .write_tx(|tx| crate::portfolio::sync(tx, req, &graph_hash, now, initial_plan))?;
1242        let (outcome, events) = sync_outcome(synced, &project, name, variant, now);
1243        self.flush_audit(events).await;
1244        Ok(outcome)
1245    }
1246
1247    async fn fork_plan(&self, req: ForkRequest) -> Result<SyncOutcome, PlannerError> {
1248        let ForkRequest {
1249            plan_id,
1250            variant,
1251            edits,
1252            project_root,
1253        } = req;
1254        crate::project::validate_slug("variant", &variant)?;
1255        let (head, info, exists, line_archived) = self.store.read_tx(|tx| {
1256            let (_, head) = crate::portfolio::revision_graph(tx, &plan_id, None)?;
1257            let info = crate::portfolio::variant_info(tx, &plan_id)?.ok_or_else(|| {
1258                PlannerError::InvalidPath {
1259                    reason: "fork requires a named plan".to_string(),
1260                }
1261            })?;
1262            let exists = crate::portfolio::has_variant(tx, &info.project, &info.name, &variant)?;
1263            let line_archived = crate::portfolio::line_is_archived(tx, &info.project, &info.name)?;
1264            Ok((head, info, exists, line_archived))
1265        })?;
1266        // Both are checked again inside the creating transaction; failing
1267        // here first avoids writing a plan file for a fork that cannot be
1268        // registered. Forking FROM an archived variant of a live line is
1269        // allowed: the source stays readable and the new draft is live.
1270        if line_archived {
1271            return Err(crate::portfolio::line_archived(&info.name));
1272        }
1273        if exists {
1274            return Err(crate::portfolio::variant_exists(&variant, &info.name));
1275        }
1276        let graph = crate::edits::apply_edits(&head, &edits)?;
1277        let root = match project_root {
1278            Some(root) if root.project_key() != info.project => {
1279                return Err(PlannerError::InvalidPath {
1280                    reason: format!(
1281                        "the project root belongs to a different project than plan {}",
1282                        plan_id.0
1283                    ),
1284                });
1285            }
1286            Some(root) => Some(root),
1287            None => self
1288                .project_root
1289                .clone()
1290                .filter(|root| root.project_key() == info.project),
1291        };
1292        let mut sync_req = SyncRequest::new(
1293            info.project.clone(),
1294            info.name.clone(),
1295            variant.clone(),
1296            graph,
1297        );
1298        let mut written = None;
1299        if let Some(root) = &root {
1300            let file = root.plan_file(&info.name, &variant)?;
1301            let hash = root.write_new_graph(&file, &sync_req.graph)?;
1302            sync_req = sync_req
1303                .with_source_path(file.rel_path.clone())
1304                .with_content_hash(hash.clone());
1305            written = Some((root, file, hash));
1306        }
1307        let graph_hash = hash_graph(&sync_req.graph);
1308        let now = self.now();
1309        let synced = self
1310            .store
1311            .write_tx(|tx| crate::portfolio::sync_new(tx, sync_req, &graph_hash, now, initial_plan))
1312            .inspect_err(|_| {
1313                // The fork was never registered: do not leave its file behind.
1314                if let Some((root, file, hash)) = &written {
1315                    root.remove_plan_file_if_unchanged(file, hash);
1316                }
1317            })?;
1318        let (outcome, events) = sync_outcome(synced, &info.project, info.name, variant, now);
1319        self.flush_audit(events).await;
1320        Ok(outcome)
1321    }
1322
1323    async fn compare_plans(
1324        &self,
1325        req: ComparePlansRequest,
1326    ) -> Result<crate::compare::Comparison, PlannerError> {
1327        let inputs = self.compare_inputs(&req)?;
1328        crate::compare::compare(&inputs, &req.request)
1329    }
1330
1331    async fn export_plan(
1332        &self,
1333        plan_id: &PlanId,
1334        root: &ProjectRoot,
1335        path: Option<&str>,
1336        force: bool,
1337    ) -> Result<String, PlannerError> {
1338        let (graph, info) = self.store.read_tx(|tx| {
1339            let (_, graph) = crate::portfolio::revision_graph(tx, plan_id, None)?;
1340            Ok((graph, crate::portfolio::variant_info(tx, plan_id)?))
1341        })?;
1342        let file = match (path, &info) {
1343            (Some(path), _) => root.resolve_plan_file(path)?,
1344            (None, Some(info)) if info.project == root.project_key() => {
1345                root.plan_file(&info.name, &info.variant)?
1346            }
1347            (None, Some(_)) => {
1348                return Err(PlannerError::InvalidPath {
1349                    reason: format!(
1350                        "plan {} belongs to a different project; give a path",
1351                        plan_id.0
1352                    ),
1353                });
1354            }
1355            (None, None) => {
1356                return Err(PlannerError::InvalidPath {
1357                    reason: format!("plan {} is unnamed; export requires a path", plan_id.0),
1358                });
1359            }
1360        };
1361        if !force {
1362            self.check_export_target(plan_id, root, &file)?;
1363        }
1364        let hash = root.write_graph(&file, &graph)?;
1365        // Exported to its own tracked file: the file now matches the head,
1366        // so record its hash (drift stays false).
1367        if let Some(info) = &info
1368            && info.project == root.project_key()
1369            && info.source_path.as_deref() == Some(file.rel_path.as_str())
1370        {
1371            self.store
1372                .write_tx(|tx| crate::portfolio::set_content_hash(tx, plan_id, &hash))?;
1373        }
1374        Ok(file.rel_path)
1375    }
1376
1377    async fn baseline(
1378        &self,
1379        req: crate::earned_value::BaselineRequest,
1380    ) -> Result<crate::earned_value::BaselineOutcome, PlannerError> {
1381        self.take_baseline(req).await
1382    }
1383
1384    async fn ev(
1385        &self,
1386        plan_id: &PlanId,
1387        as_of: Option<DateTime<Utc>>,
1388    ) -> Result<crate::earned_value::EvReport, PlannerError> {
1389        self.ev_report(plan_id, as_of)
1390    }
1391
1392    async fn snapshot(
1393        &self,
1394        req: crate::earned_value::SnapshotRequest,
1395    ) -> Result<crate::earned_value::SnapshotOutcome, PlannerError> {
1396        self.take_snapshot(req)
1397    }
1398
1399    async fn list_plans(
1400        &self,
1401        project: &str,
1402        include_archived: bool,
1403    ) -> Result<Vec<PlanLineSummary>, PlannerError> {
1404        self.store
1405            .read_tx(|tx| crate::portfolio::list(tx, project, include_archived))
1406    }
1407
1408    async fn revision_graph(
1409        &self,
1410        plan_id: &PlanId,
1411        revision: Option<u32>,
1412    ) -> Result<(u32, PlanGraph), PlannerError> {
1413        self.store
1414            .read_tx(|tx| crate::portfolio::revision_graph(tx, plan_id, revision))
1415    }
1416
1417    async fn revise_plan(&self, req: ReviseRequest) -> Result<(u32, RevisionDiff), PlannerError> {
1418        let ReviseRequest {
1419            plan_id,
1420            graph,
1421            force,
1422        } = req;
1423        let now = self.now();
1424        let revised = self.store.write_tx(|tx| {
1425            crate::portfolio::ensure_revisable(tx, &plan_id)?;
1426            crate::portfolio::revise(tx, &plan_id, graph, None, None, force, now)
1427        })?;
1428        self.flush_audit(revision_events(&plan_id, &revised, now))
1429            .await;
1430        Ok((revised.revision, revised.diff))
1431    }
1432
1433    async fn select_variant(
1434        &self,
1435        plan_id: &PlanId,
1436        force: bool,
1437    ) -> Result<SelectOutcome, PlannerError> {
1438        let now = self.now();
1439        let mut baseline_audit: Vec<AuditEvent> = Vec::new();
1440        // Selecting a variant that gains progress (carried from the previous
1441        // one, or already present) is execution-starting: in auto mode it
1442        // takes a baseline when the newly selected variant has none, in the
1443        // same write; in refuse mode it fails with BASELINE_REQUIRED.
1444        let selected = self.store.write_tx(|tx| {
1445            let mut selected = crate::portfolio::select(tx, plan_id, force, now)?;
1446            if selected.outcome.changed {
1447                let state = crate::plan_store::load_plan_state(tx, plan_id)?.ok_or_else(|| {
1448                    PlannerError::PlanNotFound {
1449                        plan_id: plan_id.0.clone(),
1450                    }
1451                })?;
1452                if ev::state_has_progress(&state) {
1453                    selected.outcome.baseline_taken = self.ensure_execution_baseline(
1454                        tx,
1455                        plan_id,
1456                        &state,
1457                        now,
1458                        &mut baseline_audit,
1459                    )?;
1460                }
1461            }
1462            Ok(selected)
1463        })?;
1464        let mut events = selection_events(&selected, now);
1465        events.extend(baseline_audit);
1466        self.flush_audit(events).await;
1467        Ok(selected.outcome)
1468    }
1469
1470    async fn archive(
1471        &self,
1472        project: &str,
1473        name: &str,
1474        variant: Option<&str>,
1475        archived: bool,
1476        force: bool,
1477    ) -> Result<(), PlannerError> {
1478        let now = self.now();
1479        let outcome = self.store.write_tx(|tx| {
1480            crate::portfolio::archive(tx, project, name, variant, archived, force, now)
1481        })?;
1482        self.flush_audit(archive_events(
1483            project, name, variant, archived, &outcome, now,
1484        ))
1485        .await;
1486        Ok(())
1487    }
1488
1489    async fn acquire_cohort(&self, req: AcquireRequest) -> Result<Cohort, PlannerError> {
1490        let AcquireRequest {
1491            plan_id,
1492            caller_id,
1493            max_count,
1494            ids,
1495            metadata_filter,
1496            ttl,
1497        } = req;
1498        let now = self.now();
1499        let expires_at = now
1500            + chrono::Duration::from_std(self.effective_ttl(ttl))
1501                .expect("INVARIANT: planner TTL fits in chrono::Duration");
1502
1503        // Whole acquire body runs inside one immediate sqlite transaction —
1504        // that's what gives us atomicity against concurrent acquirers,
1505        // including acquirers in other OS processes.
1506        let mut audit_buf: Vec<AuditEvent> = Vec::new();
1507        let store = &self.store;
1508        let cohort = store.mutate_executable_plan_tx(&plan_id, |tx, state| {
1509            // 1. Reap expired locks, emitting expiry events.
1510            let reaped = state.reap_expired(now);
1511            for lock in &reaped {
1512                audit_buf.push(make_expired_event(lock, now));
1513            }
1514
1515            // 2a. Targeting: validate requested ids, then work out which
1516            //     deliverables this acquire considers. Manual deliverables
1517            //     (metadata.kind == "manual") are never leased; a metadata
1518            //     filter silently narrows the field.
1519            if let Some(req_ids) = &ids {
1520                for rid in req_ids {
1521                    if !state.graph.deliverables.iter().any(|d| &d.id == rid) {
1522                        return Err(PlannerError::DeliverableNotFound {
1523                            plan_id: plan_id.0.clone(),
1524                            deliverable_id: rid.clone(),
1525                        });
1526                    }
1527                }
1528            }
1529            let is_requested = |id: &str| ids.as_ref().is_some_and(|v| v.iter().any(|r| r == id));
1530            let is_manual =
1531                |d: &Deliverable| d.metadata.get("kind").and_then(|k| k.as_str()) == Some("manual");
1532            let matches_filter = |d: &Deliverable| {
1533                metadata_filter
1534                    .as_ref()
1535                    .is_none_or(|f| f.iter().all(|(k, v)| d.metadata.get(k) == Some(v)))
1536            };
1537            let in_scope =
1538                |d: &Deliverable| (ids.is_none() || is_requested(&d.id)) && matches_filter(d);
1539
1540            // 2b-prelim. Lapse bound: a Ready deliverable whose lease has lapsed
1541            //     environmentally MAX_LAPSES times is evidence of a broken
1542            //     ENVIRONMENT (drivers keep getting killed before they can
1543            //     report), not a broken deliverable. Do NOT auto-fail it —
1544            //     that would misdiagnose a healthy deliverable — but stop
1545            //     re-leasing it: skip it, leave the rest of the plan
1546            //     leasable, and report it in `blocked` so an operator
1547            //     intervenes (force_release with reset_counters).
1548
1549            // 2b. Circuit-break: a Ready deliverable that has already been
1550            //     EXPLICITLY marked failed MAX_ATTEMPTS times is a poison
1551            //     item — every prior lease ended with the driver reporting
1552            //     a real implementation failure, and re-leasing it would
1553            //     loop forever. Transition it to Failed and skip it. It
1554            //     holds no lock (it is Ready), it is never handed out
1555            //     again (Failed is terminal), and its dependents simply
1556            //     never become Ready — the plan converges to `exhausted`
1557            //     instead of retrying unboundedly. Environmental lapses
1558            //     deliberately do NOT feed this counter.
1559            let poisoned: Vec<(String, u32)> = state
1560                .graph
1561                .deliverables
1562                .iter()
1563                .filter(|d| {
1564                    matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1565                        && lease_retry_decision(state.failure_count(&d.id)) == RetryDecision::Stop
1566                })
1567                .map(|d| (d.id.clone(), state.failure_count(&d.id)))
1568                .collect();
1569            for (id, failures) in poisoned {
1570                // A spent retry budget is a PERMANENT failure (not retryable) in
1571                // execution_policy's classification — the deliverable is out.
1572                let reason = format!("circuit-break: exceeded {MAX_ATTEMPTS} failed attempts");
1573                audit_buf.push(make_circuit_break_event(
1574                    &plan_id,
1575                    &id,
1576                    failures,
1577                    FailureClass::Permanent,
1578                    &reason,
1579                ));
1580                state
1581                    .statuses
1582                    .insert(id, DeliverableStatus::Failed { reason });
1583            }
1584
1585            // 2c. Lapse-limited set, computed AFTER the circuit-break so a
1586            //     deliverable at both caps is reported only as Failed.
1587            let lapse_blocked: Vec<BlockedDeliverable> = state
1588                .graph
1589                .deliverables
1590                .iter()
1591                .filter(|d| {
1592                    in_scope(d)
1593                        && !is_manual(d)
1594                        && matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1595                        && !state.locks.contains_key(&d.id)
1596                        && state.lapse_count(&d.id) >= MAX_LAPSES
1597                })
1598                .map(|d| BlockedDeliverable {
1599                    id: d.id.clone(),
1600                    code: "LAPSE_LIMIT".to_string(),
1601                    reason: format!(
1602                        "lease lapsed {} times (limit {MAX_LAPSES}); clear with \
1603                         plan.force_release {{reset_counters: true}}",
1604                        state.lapse_count(&d.id)
1605                    ),
1606                })
1607                .collect();
1608
1609            // 3. Build the (latest_start, float) lookup table.
1610            let sched_by_id: HashMap<&str, (f32, f32)> = state
1611                .cached_result
1612                .tasks
1613                .iter()
1614                .map(|t| (t.id.as_str(), (t.latest_start, t.float)))
1615                .collect();
1616
1617            // 4. Build the ready set (in scope, non-manual, Ready, unlocked,
1618            //    not lapse-limited) and, for requested ids, classify every id
1619            //    that cannot be leased. Without `ids`, only LAPSE_LIMIT is
1620            //    reported (manual deliverables are skipped quietly).
1621            let mut blocked: Vec<BlockedDeliverable> = Vec::new();
1622            let mut ready: Vec<&Deliverable> = Vec::new();
1623            if let Some(req_ids) = &ids {
1624                let mut seen: HashSet<&str> = HashSet::new();
1625                for rid in req_ids {
1626                    if !seen.insert(rid.as_str()) {
1627                        continue;
1628                    }
1629                    let d = state
1630                        .graph
1631                        .deliverables
1632                        .iter()
1633                        .find(|d| &d.id == rid)
1634                        .expect("INVARIANT: requested ids validated above");
1635                    if !matches_filter(d) {
1636                        continue;
1637                    }
1638                    let mut block = |code: &str, reason: String| {
1639                        blocked.push(BlockedDeliverable {
1640                            id: d.id.clone(),
1641                            code: code.to_string(),
1642                            reason,
1643                        });
1644                    };
1645                    let status = state.statuses.get(&d.id);
1646                    if is_manual(d) {
1647                        block("MANUAL", "manual deliverable; never leased".to_string());
1648                    } else if state.locks.contains_key(&d.id) {
1649                        block("LOCKED", "held by an active lease".to_string());
1650                    } else if !matches!(status, Some(DeliverableStatus::Ready)) {
1651                        block(
1652                            "NOT_READY",
1653                            format!(
1654                                "status is {}",
1655                                status.map_or("unknown".to_string(), |s| format!("{s:?}"))
1656                            ),
1657                        );
1658                    } else if let Some(lb) = lapse_blocked.iter().find(|b| b.id == d.id) {
1659                        block("LAPSE_LIMIT", lb.reason.clone());
1660                    } else {
1661                        ready.push(d);
1662                    }
1663                }
1664            } else {
1665                blocked = lapse_blocked.clone();
1666                ready.extend(state.graph.deliverables.iter().filter(|d| {
1667                    in_scope(d)
1668                        && !is_manual(d)
1669                        && matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1670                        && !state.locks.contains_key(&d.id)
1671                        && !lapse_blocked.iter().any(|b| b.id == d.id)
1672                }));
1673            }
1674            ready.sort_by_key(|d| priority_key(&d.id, &sched_by_id));
1675
1676            // 5. Greedy fill with file-disjointness check. Requested ids that
1677            //    are skipped are reported as FILE_CONFLICT / MAX_COUNT.
1678            let mut selected: Vec<Deliverable> = Vec::new();
1679            let mut selected_files: HashMap<PathBuf, FileMode> = HashMap::new();
1680            for candidate in ready {
1681                if selected.len() == max_count {
1682                    if ids.is_none() {
1683                        break;
1684                    }
1685                    blocked.push(BlockedDeliverable {
1686                        id: candidate.id.clone(),
1687                        code: "MAX_COUNT".to_string(),
1688                        reason: format!("cohort already holds max_count ({max_count})"),
1689                    });
1690                    continue;
1691                }
1692                let conflict = candidate.owned_files.iter().any(|f| {
1693                    selected_files
1694                        .get(f.path())
1695                        .is_some_and(|m| modes_conflict(f.mode(), *m))
1696                        || state
1697                            .file_claims
1698                            .get(f.path())
1699                            .is_some_and(|c| c.conflicts_with(f.mode()))
1700                });
1701                if conflict {
1702                    if ids.is_some() {
1703                        blocked.push(BlockedDeliverable {
1704                            id: candidate.id.clone(),
1705                            code: "FILE_CONFLICT".to_string(),
1706                            reason: "owned files overlap a held lock or an earlier pick"
1707                                .to_string(),
1708                        });
1709                    }
1710                    continue;
1711                }
1712                for f in &candidate.owned_files {
1713                    // Exclusive wins if a path is somehow listed twice.
1714                    let e = selected_files
1715                        .entry(f.path().to_path_buf())
1716                        .or_insert(f.mode());
1717                    if f.mode() == FileMode::Exclusive {
1718                        *e = FileMode::Exclusive;
1719                    }
1720                }
1721                selected.push(candidate.clone());
1722            }
1723
1724            // 6. Atomically acquire: status -> InProgress, locks inserted,
1725            //    file index updated, attempt counted, audit events buffered.
1726            //
1727            // F5 INTERFACE_GAP-001: build `Vec<CohortRow>` directly so
1728            // the pairing invariant is type-enforced — pre-F5 we
1729            // collected into `Vec<(Deliverable, LockInfo)>` then
1730            // unzipped into two parallel vectors that were doc-only
1731            // aligned.
1732            let mut rows: Vec<CohortRow> = Vec::with_capacity(selected.len());
1733            for d in selected {
1734                let lock = LockInfo {
1735                    plan_id: plan_id.clone(),
1736                    deliverable_id: d.id.clone(),
1737                    caller_id: caller_id.clone(),
1738                    acquired_at: now,
1739                    expires_at,
1740                };
1741                state
1742                    .statuses
1743                    .insert(d.id.clone(), DeliverableStatus::InProgress);
1744                // Increment on LEASE, not on candidate evaluation: only a
1745                // deliverable actually handed to a driver counts as an
1746                // attempt (a file-conflict skip above does not).
1747                *state.attempt_counts.entry(d.id.clone()).or_insert(0) += 1;
1748                add_file_claims(&mut state.file_claims, &d.id, &d.owned_files);
1749                state.locks.insert(d.id.clone(), lock.clone());
1750                // In progress by lease from here on: a lost lock row is an orphan.
1751                state.lockless.remove(&d.id);
1752                audit_buf.push(make_acquired_event(&lock, &d.owned_files));
1753                rows.push(CohortRow {
1754                    deliverable: d,
1755                    lock,
1756                });
1757            }
1758
1759            // Append paths in this cohort that two or more deliverables hold
1760            // (claims were just recorded, so the set includes cohort members).
1761            let mut shared_paths: Vec<PathBuf> = rows
1762                .iter()
1763                .flat_map(|r| r.deliverable.owned_files.iter())
1764                .filter(|f| f.mode() == FileMode::Append)
1765                .filter(|f| {
1766                    matches!(state.file_claims.get(f.path()),
1767                        Some(FileClaim::Append(set)) if set.len() >= 2)
1768                })
1769                .map(|f| f.path().to_path_buf())
1770                .collect();
1771            shared_paths.sort();
1772            shared_paths.dedup();
1773
1774            // Execution starts when at least one deliverable is leased;
1775            // then the plan needs an earned-value baseline. In auto mode
1776            // this takes one from the graph as of this call, in the same
1777            // write; in refuse mode it is a BASELINE_REQUIRED error.
1778            let baseline_taken = if rows.is_empty() {
1779                None
1780            } else {
1781                self.ensure_execution_baseline(tx, &plan_id, state, now, &mut audit_buf)?
1782            };
1783
1784            Ok(Cohort {
1785                plan_id: plan_id.clone(),
1786                rows,
1787                blocked,
1788                shared_paths,
1789                baseline_taken,
1790            })
1791        })?;
1792
1793        self.flush_audit(audit_buf).await;
1794        Ok(cohort)
1795    }
1796
1797    async fn mark_status(
1798        &self,
1799        req: MarkStatusRequest,
1800    ) -> Result<Option<BaselineTaken>, PlannerError> {
1801        let MarkStatusRequest {
1802            plan_id,
1803            deliverable_id,
1804            caller_id,
1805            status,
1806            earned_pct,
1807            actual_effort_hours,
1808            evidence,
1809        } = req;
1810        validate_actuals(
1811            &status,
1812            earned_pct.map(f64::from),
1813            actual_effort_hours.map(f64::from),
1814            evidence.as_deref(),
1815        )?;
1816        let report = crate::ev_store::ReportedActuals {
1817            // Accepted alongside Complete, but ignored there.
1818            earned_pct: earned_pct.filter(|_| status == DeliverableStatus::InProgress),
1819            actual_hours: actual_effort_hours,
1820            evidence,
1821        };
1822        let deliverable_id = deliverable_id.as_str();
1823        let caller_id = &caller_id;
1824        let now = self.now();
1825        let mut audit_buf: Vec<AuditEvent> = Vec::new();
1826        let store = &self.store;
1827        let baseline_taken = store.mutate_executable_plan_tx(&plan_id, |tx, state| {
1828            // Deliverable existence.
1829            if !state
1830                .graph
1831                .deliverables
1832                .iter()
1833                .any(|d| d.id == deliverable_id)
1834            {
1835                return Err(PlannerError::DeliverableNotFound {
1836                    plan_id: plan_id.0.clone(),
1837                    deliverable_id: deliverable_id.to_string(),
1838                });
1839            }
1840
1841            // If a lock exists it must belong to caller_id.
1842            if let Some(lock) = state.locks.get(deliverable_id)
1843                && lock.caller_id != *caller_id
1844            {
1845                return Err(PlannerError::LockNotHeld {
1846                    caller_id: caller_id.0.clone(),
1847                    deliverable_id: deliverable_id.to_string(),
1848                });
1849            }
1850
1851            let is_complete = matches!(status, DeliverableStatus::Complete);
1852            let previous_status = state
1853                .statuses
1854                .get(deliverable_id)
1855                .cloned()
1856                .unwrap_or(DeliverableStatus::Pending);
1857
1858            // No lock and already Complete: only an idempotent Complete is
1859            // allowed; a late holder of an overridden lease cannot undo an
1860            // accept.
1861            if !state.locks.contains_key(deliverable_id)
1862                && matches!(previous_status, DeliverableStatus::Complete)
1863                && !is_complete
1864            {
1865                return Err(PlannerError::LockNotHeld {
1866                    caller_id: caller_id.0.clone(),
1867                    deliverable_id: deliverable_id.to_string(),
1868                });
1869            }
1870
1871            // Progress fields ride along with every accepted mark (also an
1872            // idempotent Complete); a refused mark rolls them back.
1873            // An InProgress/Complete mark is execution-starting: ensure the
1874            // plan has a baseline (auto: take one; refuse: fail) before any
1875            // mutation is persisted.
1876            let baseline_taken = if matches!(
1877                status,
1878                DeliverableStatus::InProgress | DeliverableStatus::Complete
1879            ) {
1880                self.ensure_execution_baseline(tx, &plan_id, state, now, &mut audit_buf)?
1881            } else {
1882                None
1883            };
1884            state.report_actuals(deliverable_id, report, now);
1885
1886            // Completing without a lease: every prerequisite must be done,
1887            // and the bypass is audited.
1888            if is_complete && !state.locks.contains_key(deliverable_id) {
1889                // Already Complete: idempotent no-op, nothing to audit.
1890                if matches!(
1891                    state.statuses.get(deliverable_id),
1892                    Some(DeliverableStatus::Complete)
1893                ) {
1894                    return Ok(baseline_taken);
1895                }
1896                let missing = incomplete_prerequisites(state, deliverable_id);
1897                if !missing.is_empty() {
1898                    return Err(PlannerError::PrerequisitesIncomplete {
1899                        plan_id: plan_id.0.clone(),
1900                        deliverable_id: deliverable_id.to_string(),
1901                        missing,
1902                    });
1903                }
1904                audit_buf.push(make_completed_without_lease_event(
1905                    &plan_id,
1906                    deliverable_id,
1907                    caller_id,
1908                    &previous_status,
1909                ));
1910            } else if !is_complete && !state.locks.contains_key(deliverable_id) {
1911                // Putting a deliverable back to work without a lease must
1912                // not bypass dependency order.
1913                if matches!(
1914                    status,
1915                    DeliverableStatus::Ready | DeliverableStatus::InProgress
1916                ) {
1917                    let missing = incomplete_prerequisites(state, deliverable_id);
1918                    if !missing.is_empty() {
1919                        return Err(PlannerError::PrerequisitesIncomplete {
1920                            plan_id: plan_id.0.clone(),
1921                            deliverable_id: deliverable_id.to_string(),
1922                            missing,
1923                        });
1924                    }
1925                }
1926                audit_buf.push(make_marked_without_lease_event(
1927                    &plan_id,
1928                    deliverable_id,
1929                    caller_id,
1930                    &status,
1931                    &previous_status,
1932                ));
1933            }
1934
1935            // Lock release on Failed.
1936            if matches!(status, DeliverableStatus::Failed { .. })
1937                && let Some(lock) = state.end_lease(deliverable_id, now)
1938            {
1939                audit_buf.push(make_released_event(&lock, "failed"));
1940            }
1941
1942            // An EXPLICIT Failed mark is a real implementation attempt —
1943            // this (and only this) feeds the failure circuit-breaker.
1944            // Guarded on the prior status so an idempotent re-mark of an
1945            // already-Failed deliverable does not double-charge, and the
1946            // acquire-path auto-fail (which writes Failed directly) never
1947            // routes through here.
1948            if matches!(status, DeliverableStatus::Failed { .. })
1949                && !matches!(
1950                    state.statuses.get(deliverable_id),
1951                    Some(DeliverableStatus::Failed { .. })
1952                )
1953            {
1954                *state
1955                    .failure_counts
1956                    .entry(deliverable_id.to_string())
1957                    .or_insert(0) += 1;
1958            }
1959
1960            // Lease provenance for the startup sweep: a lockless
1961            // in_progress mark is a legitimate persisted state (owner or
1962            // manual work), not an orphan of a lost lease. Any other mark,
1963            // or one under a held lease, clears it.
1964            if status == DeliverableStatus::InProgress && !state.locks.contains_key(deliverable_id)
1965            {
1966                state.lockless.insert(deliverable_id.to_string());
1967            } else {
1968                state.lockless.remove(deliverable_id);
1969            }
1970
1971            if is_complete {
1972                if complete_deliverable(state, deliverable_id, &mut audit_buf, "completed", now) {
1973                    audit_buf.push(make_plan_completed_event(
1974                        &plan_id,
1975                        state.graph.deliverables.len(),
1976                    ));
1977                }
1978            } else {
1979                state
1980                    .statuses
1981                    .insert(deliverable_id.to_string(), status.clone());
1982            }
1983
1984            Ok(baseline_taken)
1985        })?;
1986
1987        // SPEC §33 audit fixup (F6 ORPHAN-001): the previous
1988        // `let _ = status_recompute_needed;` extension marker was
1989        // computed but never consumed. YAGNI — the CPM algorithm
1990        // doesn't drift with status alone, and a future "filter on
1991        // non-complete tasks" refactor can recompute the flag when
1992        // it actually needs it.
1993
1994        self.flush_audit(audit_buf).await;
1995        Ok(baseline_taken)
1996    }
1997
1998    async fn heartbeat(&self, req: HeartbeatRequest) -> Result<(), PlannerError> {
1999        let HeartbeatRequest {
2000            plan_id,
2001            deliverable_id,
2002            caller_id,
2003            ttl,
2004        } = req;
2005        let deliverable_id = deliverable_id.as_str();
2006        let caller_id = &caller_id;
2007        let now = self.now();
2008        let explicit_ttl = ttl.is_some();
2009        let expires_at = now
2010            + chrono::Duration::from_std(self.effective_ttl(ttl))
2011                .expect("INVARIANT: planner TTL fits in chrono::Duration");
2012
2013        self.store.mutate_executable_plan(&plan_id, |state| {
2014            let lock =
2015                state
2016                    .locks
2017                    .get_mut(deliverable_id)
2018                    .ok_or_else(|| PlannerError::LockNotHeld {
2019                        caller_id: caller_id.0.clone(),
2020                        deliverable_id: deliverable_id.to_string(),
2021                    })?;
2022
2023            if lock.caller_id != *caller_id {
2024                return Err(PlannerError::LockNotHeld {
2025                    caller_id: caller_id.0.clone(),
2026                    deliverable_id: deliverable_id.to_string(),
2027                });
2028            }
2029
2030            // TTL already lapsed at the moment of the heartbeat — surface it so
2031            // the caller knows their work item may have been reclaimed.
2032            if lock.expires_at < now {
2033                return Err(PlannerError::LockExpired {
2034                    deliverable_id: deliverable_id.to_string(),
2035                    expired_at: lock.expires_at,
2036                });
2037            }
2038
2039            // A heartbeat without an explicit ttl never shortens a lease
2040            // (e.g. one acquired with a long ttl_seconds); an explicit ttl
2041            // sets now + ttl.
2042            lock.expires_at = if explicit_ttl {
2043                expires_at
2044            } else {
2045                lock.expires_at.max(expires_at)
2046            };
2047            Ok(())
2048        })
2049    }
2050
2051    async fn get_plan(&self, plan_id: &PlanId) -> Result<PlanDefinition, PlannerError> {
2052        self.store.read_plan(plan_id, |state| PlanDefinition {
2053            plan_id: plan_id.clone(),
2054            graph: state.graph.clone(),
2055        })
2056    }
2057
2058    async fn status(&self, plan_id: &PlanId) -> Result<PlanStatus, PlannerError> {
2059        let (((mut status, head_hash), info), baseline) =
2060            self.store.read_plan_and_variant(plan_id, |state| {
2061                // Preserve insertion order from the original graph for stable UI.
2062                let deliverables: Vec<(String, DeliverableStatus, u32, u32, u32)> = state
2063                    .graph
2064                    .deliverables
2065                    .iter()
2066                    .map(|d| {
2067                        let status = state
2068                            .statuses
2069                            .get(&d.id)
2070                            .cloned()
2071                            .unwrap_or(DeliverableStatus::Pending);
2072                        (
2073                            d.id.clone(),
2074                            status,
2075                            state.attempt_count(&d.id),
2076                            state.failure_count(&d.id),
2077                            state.lapse_count(&d.id),
2078                        )
2079                    })
2080                    .collect();
2081
2082                let schedule = crate::schedule::schedule_rows(&state.graph, &state.cached_result);
2083                let mut ready_rows: Vec<&ScheduleRow> = schedule
2084                    .iter()
2085                    .filter(|r| {
2086                        matches!(state.statuses.get(&r.id), Some(DeliverableStatus::Ready))
2087                            && !state.locks.contains_key(&r.id)
2088                    })
2089                    .collect();
2090                // Display order is float-first (ready_display_key); acquire_cohort keeps
2091                // its own longest-remaining-tail priority_key. Membership is a superset:
2092                // acquire may still skip deliverables at the failure or lapse cap, manual
2093                // deliverables, or ones whose files overlap a held lock.
2094                let sched_by_id: HashMap<&str, (f32, f32)> = state
2095                    .cached_result
2096                    .tasks
2097                    .iter()
2098                    .map(|t| (t.id.as_str(), (t.latest_start, t.float)))
2099                    .collect();
2100                ready_rows.sort_by_key(|r| ready_display_key(&r.id, &sched_by_id));
2101                let ready: Vec<String> = ready_rows.iter().map(|r| r.id.clone()).collect();
2102
2103                let milestones =
2104                    crate::schedule::milestone_rows(&state.graph, &state.cached_result, |id| {
2105                        matches!(state.statuses.get(id), Some(DeliverableStatus::Complete))
2106                    });
2107
2108                let status = PlanStatus {
2109                    plan_id: plan_id.clone(),
2110                    milestones,
2111                    critical_ids: state
2112                        .cached_result
2113                        .critical_ids
2114                        .iter()
2115                        .filter(|id| id.as_str() != START_ID && id.as_str() != FINISH_ID)
2116                        .cloned()
2117                        .collect(),
2118                    plan_complete: all_complete(state),
2119                    schedule,
2120                    ready,
2121                    deliverables,
2122                    critical_path: state.cached_result.critical_path.clone(),
2123                    critical_path_hours: state.cached_result.critical_path_duration,
2124                    locks_held: state.locks.values().cloned().collect(),
2125                    name: None,
2126                    variant: None,
2127                    selected: None,
2128                    definition_drift: None,
2129                    baseline: None,
2130                };
2131                (status, hash_graph(&state.graph))
2132            })?;
2133        status.baseline = baseline;
2134        if let Some(info) = info {
2135            // File I/O outside the read snapshot; the head hash is from it.
2136            status.definition_drift =
2137                definition_drift(self.project_root.as_ref(), &info, &head_hash);
2138            status.name = Some(info.name);
2139            status.variant = Some(info.variant);
2140            status.selected = Some(info.selected);
2141        }
2142        Ok(status)
2143    }
2144
2145    async fn accept(&self, req: AcceptRequest) -> Result<(), PlannerError> {
2146        let mut audit_buf: Vec<AuditEvent> = Vec::new();
2147        let plan_id = req.plan_id.clone();
2148        let deliverable_id = req.deliverable_id.as_str();
2149        let now = self.now();
2150        self.store.mutate_executable_plan(&plan_id, |state| {
2151            if !state
2152                .graph
2153                .deliverables
2154                .iter()
2155                .any(|d| d.id == deliverable_id)
2156            {
2157                return Err(PlannerError::DeliverableNotFound {
2158                    plan_id: plan_id.0.clone(),
2159                    deliverable_id: deliverable_id.to_string(),
2160                });
2161            }
2162
2163            // Only LIVE leases block acceptance: reap expired ones first.
2164            for lock in &state.reap_expired(now) {
2165                audit_buf.push(make_expired_event(lock, now));
2166            }
2167
2168            // Already Complete: idempotent no-op (no state change, no audit).
2169            let previous_status = state
2170                .statuses
2171                .get(deliverable_id)
2172                .cloned()
2173                .unwrap_or(DeliverableStatus::Pending);
2174            if matches!(previous_status, DeliverableStatus::Complete) {
2175                return Ok(());
2176            }
2177
2178            // ANY live lease needs an explicit override, whoever the
2179            // acceptor claims to be.
2180            let mut overrode: Option<String> = None;
2181            if let Some(lock) = state.locks.get(deliverable_id) {
2182                if !req.override_lock {
2183                    return Err(PlannerError::LockHeld {
2184                        plan_id: plan_id.0.clone(),
2185                        deliverable_id: deliverable_id.to_string(),
2186                        holder: lock.caller_id.0.clone(),
2187                    });
2188                }
2189                overrode = Some(lock.caller_id.0.clone());
2190            }
2191
2192            let missing = incomplete_prerequisites(state, deliverable_id);
2193            if !missing.is_empty() {
2194                return Err(PlannerError::PrerequisitesIncomplete {
2195                    plan_id: plan_id.0.clone(),
2196                    deliverable_id: deliverable_id.to_string(),
2197                    missing,
2198                });
2199            }
2200
2201            let plan_done =
2202                complete_deliverable(state, deliverable_id, &mut audit_buf, "accepted", now);
2203            audit_buf.push(make_accepted_event(
2204                &req,
2205                overrode.as_deref(),
2206                &previous_status,
2207            ));
2208            if plan_done {
2209                audit_buf.push(make_plan_completed_event(
2210                    &plan_id,
2211                    state.graph.deliverables.len(),
2212                ));
2213            }
2214            Ok(())
2215        })?;
2216
2217        self.flush_audit(audit_buf).await;
2218        Ok(())
2219    }
2220
2221    async fn force_release(&self, req: ForceReleaseRequest) -> Result<(), PlannerError> {
2222        let ForceReleaseRequest {
2223            plan_id,
2224            deliverable_id,
2225            reason,
2226            reset_counters,
2227        } = req;
2228        let deliverable_id = deliverable_id.as_str();
2229        let reason = reason.as_str();
2230        let now = self.now();
2231        let mut audit_buf: Vec<AuditEvent> = Vec::new();
2232        self.store.mutate_executable_plan(&plan_id, |state| {
2233            if !state
2234                .graph
2235                .deliverables
2236                .iter()
2237                .any(|d| d.id == deliverable_id)
2238            {
2239                return Err(PlannerError::DeliverableNotFound {
2240                    plan_id: plan_id.0.clone(),
2241                    deliverable_id: deliverable_id.to_string(),
2242                });
2243            }
2244
2245            if let Some(lock) = state.end_lease(deliverable_id, now) {
2246                state
2247                    .statuses
2248                    .insert(deliverable_id.to_string(), DeliverableStatus::Ready);
2249                audit_buf.push(make_force_released_event(&lock, reason));
2250            }
2251
2252            if reset_counters {
2253                let lapse_count = state.lapse_counts.remove(deliverable_id).unwrap_or(0);
2254                let failure_count = state.failure_counts.remove(deliverable_id).unwrap_or(0);
2255                // A circuit-broken deliverable is Failed; revive it.
2256                if matches!(
2257                    state.statuses.get(deliverable_id),
2258                    Some(DeliverableStatus::Failed { .. })
2259                ) {
2260                    let prereqs_complete = state
2261                        .graph
2262                        .deliverables
2263                        .iter()
2264                        .find(|d| d.id == deliverable_id)
2265                        .is_some_and(|d| {
2266                            crate::graph::prerequisite_ids(d).all(|p| {
2267                                matches!(state.statuses.get(p), Some(DeliverableStatus::Complete))
2268                            })
2269                        });
2270                    let revived = if prereqs_complete {
2271                        DeliverableStatus::Ready
2272                    } else {
2273                        DeliverableStatus::Pending
2274                    };
2275                    state.statuses.insert(deliverable_id.to_string(), revived);
2276                }
2277                audit_buf.push(make_counters_reset_event(
2278                    &plan_id,
2279                    deliverable_id,
2280                    reason,
2281                    lapse_count,
2282                    failure_count,
2283                ));
2284            }
2285
2286            Ok(())
2287        })?;
2288
2289        self.flush_audit(audit_buf).await;
2290        Ok(())
2291    }
2292}
2293
2294#[cfg(test)]
2295#[allow(clippy::float_cmp)]
2296mod tests {
2297    use super::*;
2298    use crate::estimator::EffortEstimator;
2299    use crate::schedule::deliverable_to_task;
2300    use crate::task::TaskKind;
2301
2302    fn deliverable(id: &str, effort: Option<f32>, metadata: serde_json::Value) -> Deliverable {
2303        Deliverable {
2304            id: id.to_string(),
2305            owned_files: Vec::new(),
2306            prerequisites: Vec::new(),
2307            estimated_effort_hours: effort,
2308            metadata,
2309            duration_hours: None,
2310            estimate: None,
2311            milestone: false,
2312            earning_rule: None,
2313        }
2314    }
2315
2316    #[test]
2317    fn explicit_default_earning_rule_hashes_like_absent() {
2318        let absent = deliverable("D1", Some(1.0), json!({}));
2319        let mut explicit = absent.clone();
2320        explicit.earning_rule = Some(crate::plan::EarningRule::ZeroHundred);
2321        let graph = |d: Deliverable| PlanGraph {
2322            deliverables: vec![d],
2323            max_chained_dispatch: None,
2324        };
2325        assert_eq!(hash_graph(&graph(explicit)), hash_graph(&graph(absent)));
2326    }
2327
2328    #[test]
2329    fn canonical_form_omits_an_absent_earning_rule() {
2330        let d = deliverable("D1", Some(1.0), json!({}));
2331        assert!(canonical_deliverable(&d).get("earning_rule").is_none());
2332    }
2333
2334    #[test]
2335    fn explicit_effort_wins_over_estimator() {
2336        let estimator = EffortEstimator::new();
2337        let d = deliverable("D1", Some(2.5), json!({}));
2338        let task = deliverable_to_task(&d, &estimator);
2339        assert_eq!(task.effort_hours, 2.5);
2340    }
2341
2342    #[test]
2343    fn missing_effort_uses_estimator_not_flat_default() {
2344        // No explicit estimate -> estimator's Custom base (default 4.0),
2345        // which must differ from the legacy flat DEFAULT_EFFORT_HOURS (1.0).
2346        let estimator = EffortEstimator::new();
2347        let d = deliverable("D1", None, json!({}));
2348        let task = deliverable_to_task(&d, &estimator);
2349        let expected = estimator.estimate(
2350            &TaskKind::Custom {
2351                description: String::new(),
2352            },
2353            false,
2354        );
2355        assert_eq!(task.effort_hours, expected);
2356        assert_ne!(task.effort_hours, DEFAULT_EFFORT_HOURS);
2357    }
2358
2359    #[test]
2360    fn complexity_metadata_hint_raises_estimate() {
2361        let estimator = EffortEstimator::new();
2362        let simple = deliverable_to_task(&deliverable("S", None, json!({})), &estimator);
2363        let complex = deliverable_to_task(
2364            &deliverable("C", None, json!({ "complexity": true })),
2365            &estimator,
2366        );
2367        assert!(complex.effort_hours > simple.effort_hours);
2368    }
2369
2370    fn sched<'a>(entries: &[(&'a str, f32, f32)]) -> HashMap<&'a str, (f32, f32)> {
2371        entries
2372            .iter()
2373            .map(|&(id, latest_start, float)| (id, (latest_start, float)))
2374            .collect()
2375    }
2376
2377    #[test]
2378    fn priority_key_orders_lower_latest_start_first() {
2379        let s = sched(&[("A", 5.0, 0.0), ("B", 0.0, 9.0)]);
2380        assert!(priority_key("B", &s) < priority_key("A", &s));
2381    }
2382
2383    #[test]
2384    fn priority_key_breaks_latest_start_ties_by_float() {
2385        let s = sched(&[("X", 1.0, 3.0), ("Y", 1.0, 1.0)]);
2386        assert!(priority_key("Y", &s) < priority_key("X", &s));
2387    }
2388
2389    #[test]
2390    fn priority_key_breaks_float_ties_by_id() {
2391        let s = sched(&[("X", 1.0, 1.0), ("Y", 1.0, 1.0)]);
2392        assert!(priority_key("X", &s) < priority_key("Y", &s));
2393    }
2394
2395    #[test]
2396    #[should_panic(expected = "absent from the cached CPM schedule")]
2397    fn priority_key_missing_entry_is_invariant_breach() {
2398        let s: HashMap<&str, (f32, f32)> = HashMap::new();
2399        let _ = priority_key("ghost", &s);
2400    }
2401}