Skip to main content

planarian_exec/
lib.rs

1//! The executor (SPEC §12): the one path by which anything changes.
2//!
3//! A `Proposal` from the diagnoser, the intent matcher or a user goes through
4//! the steps below, in this order:
5//! 1. digest check (`Stale`);
6//! 2. caller identity from the socket, overwriting any actor in the proposal;
7//! 3. for `ApplyChange`: catalog handler → stage → evaluate → build (read-only);
8//! 4. facts derived from the evaluated generation (I14);
9//! 5. refinement check of `expected` against `predict`;
10//! 6. `policy::decide`;
11//! 7. stale check of the owner's source, then `Prepared` (durable);
12//! 8. AskFirst parks with a token bound to the concrete candidate, and
13//!    approval re-derives everything and compares the binding. Otherwise the
14//!    kernel runs it to a terminal state.
15
16#![allow(clippy::result_large_err)] // `Submitted` is the error type on purpose: a refusal is a result.
17
18pub mod derive;
19pub mod host;
20
21use derive::{DeriveInput, derive, manifest_of};
22use host::ExecHost;
23use planarian_changes::CatalogConfig;
24use planarian_policy::{Input, Settings, Tables, decide, may_approve, policy_version};
25use planarian_schema::spec::{precondition, predict, refines};
26use planarian_schema::*;
27use planarian_state::Canonical;
28use planarian_txn::journal::{Journal, JournalError};
29use planarian_txn::kernel::{Kernel, KernelError};
30use planarian_txn::ledger::{LedgerError, Record};
31use planarian_txn::store::Store;
32use serde::{Deserialize, Serialize};
33use std::collections::BTreeMap;
34
35pub const GENERATION_DEADLINE: Millis = Millis::secs(15 * 60);
36pub const NETWORK_DEADLINE: Millis = Millis::secs(90);
37
38#[derive(Clone, Debug)]
39pub struct Config {
40    pub settings: Settings,
41    pub tables: Tables,
42    pub catalog: CatalogConfig,
43    pub owner: PinnedSource,
44    pub canonical: Canonical,
45}
46
47/// `Parked` carries the derived facts for display; results are rare and short-lived,
48/// so the size difference between variants doesn't matter.
49#[allow(clippy::large_enum_variant)]
50#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
51pub enum Submitted {
52    Rejected {
53        reason: RejectReason,
54        detail: String,
55        digest: Option<Hash>,
56    },
57    /// AskFirst: waiting for `planarian approve <approve>`.
58    Parked {
59        txn: TxnId,
60        approve: Hash,
61        facts: DerivedFacts,
62    },
63    Finished {
64        txn: TxnId,
65        state: TxnState,
66        followup: Option<Proposal>,
67    },
68    Error(String),
69}
70
71#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
72pub struct Parked {
73    pub txn: TxnId,
74    pub proposal: Proposal,
75    pub authz: Authorization,
76    /// Who may approve it (the decision's class, §11).
77    pub class: ApproverClass,
78    pub bound_to: Hash,
79    /// When the token was issued, on the boot clock (§12h): moving the wall
80    /// clock cannot extend it, and it does not outlive the boot.
81    pub issued: Timestamp,
82    pub facts: DerivedFacts,
83}
84
85/// Everything decided before a transaction exists.
86#[derive(Clone)]
87struct Plan {
88    transition: Transition,
89    facts: DerivedFacts,
90    decision: Decision,
91    base_eval: EvaluatedGeneration,
92    cand_eval: Option<EvaluatedGeneration>,
93    built: Option<Built>,
94    staged: Option<StagedSource>,
95    candidate: Option<(GenerationId, StorePath)>,
96    base_override: Option<Hash>,
97    candidate_override: Option<Hash>,
98    /// An existing generation's dry activation (R04); a built candidate's
99    /// is in `built`.
100    existing_activation: Option<ActivationPlan>,
101    /// What derive and decide read, kept to do both again when the
102    /// transaction leaves Built (C2).
103    authz: Authorization,
104    live: OverrideSet,
105    cand_overrides: Option<OverrideSet>,
106}
107
108impl Plan {
109    fn activation(&self) -> Option<&ActivationPlan> {
110        self.built.as_ref().map(|b| &b.activation).or(self.existing_activation.as_ref())
111    }
112}
113
114pub struct Executor<B: Backends + Clone, S: Store> {
115    pub kernel: Kernel<S, ExecHost<B>>,
116    pub b: B,
117    pub cfg: Config,
118    pub session: Session,
119    pub parked: BTreeMap<TxnId, Parked>,
120    /// The derived facts and decision of the last submission that got that
121    /// far, for the executor log (I7).
122    pub last: Option<(DerivedFacts, Decision)>,
123    /// Untracked owner files the last submission's staging left out (§9
124    /// step 1: Nix does not see them; they are reported, never an error).
125    pub excluded: Vec<String>,
126    /// The result of a `PrebuildJob` run off the daemon's core thread; the
127    /// next `submit` of the same transition uses it if the live override
128    /// file is still the one it was built from.
129    pub prebuilt: Option<Prebuilt>,
130    /// Set when the last commit raised the security floor (§12e): the
131    /// generation and the packages it fixed, for the daemon to report.
132    pub floor_raised: Option<(GenerationId, Vec<String>)>,
133    /// Generation transactions waiting in `Built` for an activation window
134    /// (§12f), with when each began waiting (`CLOCK_BOOTTIME`).
135    waiting: BTreeMap<TxnId, (Timestamp, Plan)>,
136}
137
138/// What stage, evaluate and build produced for a catalog change.
139#[derive(Clone, Debug)]
140pub struct Candidate {
141    staged: StagedSource,
142    eval: EvaluatedGeneration,
143    built: Built,
144    merged: OverrideSet,
145}
146
147/// A `PrebuildJob`'s result.
148#[derive(Clone, Debug)]
149pub struct Prebuilt {
150    transition: Transition,
151    live_hash: Hash,
152    result: Result<Candidate, Submitted>,
153}
154
155/// The slow, pre-journal part of planning a catalog change (§9 steps 1–3:
156/// stage, evaluate, build), detached from the executor so the daemon can run
157/// it on another thread while its core keeps observing and repairing. It
158/// touches no journal: a candidate is only a store path and a GC root until
159/// a transaction is prepared for it.
160pub struct PrebuildJob<G> {
161    gens: G,
162    owner: PinnedSource,
163    catalog: CatalogConfig,
164    transition: Transition,
165    live: OverrideSet,
166    live_hash: Hash,
167}
168
169impl<G: GenerationBackend> PrebuildJob<G> {
170    pub fn run(self) -> Prebuilt {
171        let result = build_candidate(&self.gens, &self.owner, &self.catalog, &self.transition, &self.live);
172        Prebuilt { transition: self.transition, live_hash: self.live_hash, result }
173    }
174}
175
176fn build_candidate<G: GenerationBackend>(
177    gens: &G,
178    owner: &PinnedSource,
179    catalog: &CatalogConfig,
180    transition: &Transition,
181    live: &OverrideSet,
182) -> Result<Candidate, Submitted> {
183    let Transition::ApplyChange { change, args } = transition else {
184        return Err(reject(RejectReason::Other("prebuild".into()), "not a catalog change"));
185    };
186    let set = planarian_changes::handle(catalog, change, args).map_err(|e| match e {
187        planarian_changes::CatalogError::NotInCatalog(_) => reject(RejectReason::NotInCatalog, e.to_string()),
188        e => reject(RejectReason::Other("arguments".into()), e.to_string()),
189    })?;
190    let merged = planarian_changes::merge(live, &set);
191    let fail = |what: &str, o: Outcome| reject(RejectReason::Other(what.into()), outcome_msg(o));
192    let no_space = |what: &str, o: Outcome| {
193        if outcome_msg(o.clone()).contains("No space") {
194            reject(RejectReason::NoSpace, outcome_msg(o))
195        } else {
196            fail(what, o)
197        }
198    };
199    let staged = gens.stage(owner, &merged).map_err(|o| no_space("stage", o))?;
200    let eval = gens.evaluate(&staged).map_err(|o| fail("evaluate", o))?;
201    let built = gens.build(&eval).map_err(|o| {
202        if outcome_msg(o.clone()).contains("dry activation") {
203            reject(RejectReason::ActivationUnknown, outcome_msg(o))
204        } else {
205            no_space("build", o)
206        }
207    })?;
208    Ok(Candidate { staged, eval, built, merged })
209}
210
211/// An approval token issued at `issued` has expired by `now`, both on the
212/// boot clock (§12h; §12 step 4:
213/// 24 hours). Saturating: a token issued near the end of time never expires
214/// early, and no clock value makes this panic.
215/// Why `t`'s precondition does not hold for `by` (§12 step 5), if it does
216/// not. An attested override is replaced only by its holder; anyone else
217/// gets its holder named (C3).
218pub fn unmet(t: &Transition, s: &SystemState, defs: &UnitDefs, by: &Actor) -> Option<String> {
219    if let Transition::SetOverride(sup) = t {
220        let o = s.override_of(&sup.unit)?;
221        if o.attribution == Attribution::Attested && o.by.principal() != by.principal() {
222            let holder = o.by.uid().map(|u| format!("uid {u}")).unwrap_or_else(|| format!("{:?}", o.by));
223            return Some(format!("{} holds an override set by {holder}; they lift it (planarian release)", sup.unit));
224        }
225        return None;
226    }
227    (!precondition(t, s, defs)).then(|| format!("the precondition of {t:?} does not hold"))
228}
229
230pub fn token_expired(now: Timestamp, issued: Timestamp) -> bool {
231    now > issued.plus(TOKEN_LIFETIME)
232}
233
234fn reject(reason: RejectReason, detail: impl Into<String>) -> Submitted {
235    Submitted::Rejected { reason, detail: detail.into(), digest: None }
236}
237
238fn outcome_msg(o: Outcome) -> String {
239    match o {
240        Outcome::Succeeded => "succeeded".into(),
241        Outcome::Failed(m) | Outcome::Partial(m) | Outcome::Unknown(m) => m,
242    }
243}
244
245impl<B: Backends + Clone, S: Store> Executor<B, S> {
246    pub fn new(journal: Journal<S>, b: B, me: OwnerId, cfg: Config) -> Self {
247        let host = ExecHost::new(b.clone(), cfg.canonical.clone());
248        Executor {
249            kernel: Kernel::new(journal, host, me),
250            b,
251            cfg,
252            session: Session::default(),
253            parked: BTreeMap::new(),
254            last: None,
255            excluded: vec![],
256            prebuilt: None,
257            floor_raised: None,
258            waiting: BTreeMap::new(),
259        }
260    }
261
262    pub fn policy_version(&self) -> PolicyVersion {
263        policy_version(&self.cfg.tables)
264    }
265
266    /// At startup: make sure the running generation's override content is
267    /// recorded, so a later Rollback to it can restore it.
268    pub fn adopt_current(&self) {
269        let gens = self.b.generations();
270        // C4: a build root left by a build that crashed goes once it is old.
271        let _ = gens.age_out_build_roots();
272        if let (Ok(g), Ok((set, hash))) = (gens.current(), gens.read_overrides())
273            && gens.override_for(g).ok().flatten().is_none()
274        {
275            let _ = gens.remember_override(&set);
276            let _ = gens.record_override(g, &hash);
277        }
278    }
279
280    /// Keep the probe-run baselines and the GC protected set current.
281    pub fn update_context(&mut self, ctx: planarian_state::Context, protect: Vec<GenerationId>) {
282        *self.kernel.host.ctx.lock().unwrap_or_else(|e| e.into_inner()) = ctx;
283        *self.kernel.host.protect.lock().unwrap_or_else(|e| e.into_inner()) = protect;
284    }
285
286    fn eval_of(&self, g: GenerationId) -> Result<EvaluatedGeneration, String> {
287        let gens = self.b.generations();
288        let path = gens.path_of(g).map_err(outcome_msg)?;
289        gens.evaluate_built(&path).map_err(outcome_msg)
290    }
291
292    fn plan(
293        &self,
294        p: &Proposal,
295        authz: &Authorization,
296        state: &SystemState,
297        defs: &UnitDefs,
298        prebuilt: Option<Prebuilt>,
299    ) -> Result<Plan, Submitted> {
300        let gens = self.b.generations();
301        // §11: actor fields inside a proposal are overwritten from the caller.
302        let transition = match &p.transition {
303            Transition::SetOverride(s) => Transition::SetOverride(Override { by: authz.actor.clone(), ..s.clone() }),
304            t => t.clone(),
305        };
306        // Only generation changes need the running generation's evaluation and
307        // the override file; unit operations keep working on generations that
308        // predate Planarian.
309        let gen_op = transition.changes_generation();
310        let base_eval = match self.eval_of(state.generation) {
311            Ok(e) => e,
312            Err(_) if !gen_op => EvaluatedGeneration {
313                source: Hash::ZERO,
314                options: BTreeMap::new(),
315                units: defs.clone(),
316                user_units: vec![],
317                activation: ActivationPlan::default(),
318                locked: Default::default(),
319                core_units: BTreeMap::new(),
320                packages: BTreeMap::new(),
321                insecure: Default::default(),
322                declarations: BTreeMap::new(),
323                inputs: BTreeMap::new(),
324                services: BTreeMap::new(),
325            },
326            Err(e) => {
327                return Err(reject(
328                    RejectReason::Other("evaluation".into()),
329                    format!(
330                        "the running generation's manifest is unavailable ({e}); rebuild once with Planarian enabled"
331                    ),
332                ));
333            }
334        };
335        let (live, live_hash) = match gens.read_overrides() {
336            Ok(x) => x,
337            Err(_) if !gen_op => (OverrideSet::default(), Hash::ZERO),
338            Err(o) => return Err(reject(RejectReason::Other("overrides".into()), outcome_msg(o))),
339        };
340
341        let mut plan = Plan {
342            transition: transition.clone(),
343            facts: derive(&DeriveInput {
344                transition: &transition,
345                defs,
346                base: &base_eval,
347                candidate: None,
348                activation: None,
349                base_overrides: &live,
350                candidate_overrides: None,
351                caller: authz.actor.clone(),
352                tables: &self.cfg.tables,
353                canonical: &self.cfg.canonical,
354                state,
355            }),
356            decision: Decision::Reject(RejectReason::Other("undecided".into())),
357            base_eval: base_eval.clone(),
358            cand_eval: None,
359            built: None,
360            staged: None,
361            candidate: None,
362            base_override: None,
363            candidate_override: None,
364            existing_activation: None,
365            authz: authz.clone(),
366            live: live.clone(),
367            cand_overrides: None,
368        };
369
370        let mut cand_overrides: Option<OverrideSet> = None;
371        match &transition {
372            Transition::ApplyChange { change, args } => {
373                let _ = (change, args);
374                // Built off the core thread if the daemon did so for this
375                // very transition from this very override file; else here.
376                let pre = match prebuilt {
377                    Some(p) if p.transition == transition && p.live_hash == live_hash => Some(p),
378                    Some(stale) => {
379                        if let Ok(c) = &stale.result {
380                            gens.release_root(&c.built.root);
381                        }
382                        None
383                    }
384                    None => None,
385                };
386                let Candidate { staged, eval, built, merged } = match pre {
387                    Some(p) => p.result?,
388                    None => build_candidate(gens, &self.cfg.owner, &self.cfg.catalog, &transition, &live)?,
389                };
390                let fail = |what: &str, o: Outcome| reject(RejectReason::Other(what.into()), outcome_msg(o));
391                gens.remember_override(&live).map_err(|o| fail("override", o))?;
392                let cand_hash = gens.remember_override(&merged).map_err(|o| fail("override", o))?;
393                plan.candidate = Some((built.generation, built.path.clone()));
394                plan.base_override = Some(live_hash);
395                plan.candidate_override = Some(cand_hash);
396                plan.cand_eval = Some(eval);
397                plan.built = Some(built);
398                plan.staged = Some(staged);
399                cand_overrides = Some(merged);
400            }
401            Transition::SwitchGeneration(g) | Transition::Revert { to: g } => {
402                cand_overrides = self.plan_existing(&mut plan, *g, live_hash)?;
403            }
404            Transition::Rollback => {
405                cand_overrides = self.plan_existing(&mut plan, state.known_good, live_hash)?;
406            }
407            _ => {}
408        }
409
410        plan.cand_overrides = cand_overrides;
411        plan.facts = self.facts_of(&plan, state, defs);
412
413        // §12 step 3: `expected` must refine the spec's prediction.
414        let predicted = predict(&transition, state, defs);
415        if let Err(e) = refines(&p.expected, &predicted, Some(plan.facts.affected_units.as_slice())) {
416            self.release(&plan);
417            return Err(reject(RejectReason::Refinement, e));
418        }
419
420        plan.decision = self.decision_of(&plan, state);
421        Ok(plan)
422    }
423
424    /// §11: the facts of a plan, from the current observation.
425    fn facts_of(&self, plan: &Plan, state: &SystemState, defs: &UnitDefs) -> DerivedFacts {
426        let transition = &plan.transition;
427        let mut facts = derive(&DeriveInput {
428            transition,
429            defs,
430            base: &plan.base_eval,
431            candidate: plan.cand_eval.as_ref(),
432            activation: plan.activation(),
433            base_overrides: &plan.live,
434            candidate_overrides: plan.cand_overrides.as_ref(),
435            caller: plan.authz.actor.clone(),
436            tables: &self.cfg.tables,
437            canonical: &self.cfg.canonical,
438            state,
439        });
440        // §11: who else this reaches, and §12e: whether it goes below the floor.
441        let sessions = self.b.sessions().sessions().unwrap_or_default();
442        derive::session_facts(&mut facts, &sessions);
443        derive::floor_fact(&mut facts, transition, state);
444        // §11: and whether a restart restores an unattributed stop.
445        let ended = match transition {
446            Transition::RestartUnit(u) => self.b.units().ended_by(u),
447            _ => None,
448        };
449        derive::stop_fact(&mut facts, transition, state, defs, ended);
450        facts
451    }
452
453    fn decision_of(&self, plan: &Plan, state: &SystemState) -> Decision {
454        let locked: Vec<String> =
455            plan.cand_eval.as_ref().map(|e| e.locked.iter().cloned().collect()).unwrap_or_default();
456        decide(&Input {
457            transition: &plan.transition,
458            facts: &plan.facts,
459            known_good: state.known_good,
460            live_tested: state.live_tested,
461            cumulative: &self.session.cumulative,
462            authz: &plan.authz,
463            settings: &self.cfg.settings,
464            tables: &self.cfg.tables,
465            locked: &locked,
466            revert_to: None,
467        })
468    }
469
470    /// C2 (§9 step 6, I8): a transaction leaving Built for Activating is
471    /// checked again as on the way in: the owner's source and the override
472    /// file are as it was built from, and the decision made again from the
473    /// facts derived now is the one it was admitted with. Otherwise it ends Rejected
474    /// (Stale) and is asked for again. `None` when it may go on.
475    fn revalidate(&mut self, id: TxnId, plan: &Plan, state: &SystemState, defs: &UnitDefs) -> Option<Submitted> {
476        let overrides_moved = plan
477            .base_override
478            .is_some_and(|b| self.b.generations().read_overrides().map(|(_, h)| h != b).unwrap_or(true));
479        let mut again = plan.clone();
480        again.facts = self.facts_of(&again, state, defs);
481        let redecided = self.decision_of(&again, state);
482        if !self.stale(plan) && !overrides_moved && redecided == plan.decision {
483            return None;
484        }
485        self.release(plan);
486        Some(match self.kernel.reject(id, RejectReason::Stale) {
487            Ok(end) => Submitted::Finished { txn: id, state: end, followup: None },
488            Err(e) => Self::kernel_error(e),
489        })
490    }
491
492    /// Equivalent effects, equivalent facts (§11, R04): an existing
493    /// generation's facts come from its dry activation and the override
494    /// content recorded for it, as a built candidate's do. Returns that
495    /// content, if one was recorded.
496    fn plan_existing(
497        &self,
498        plan: &mut Plan,
499        g: GenerationId,
500        live_hash: Hash,
501    ) -> Result<Option<OverrideSet>, Submitted> {
502        let gens = self.b.generations();
503        let fail = |o: Outcome| reject(RejectReason::Other("generation".into()), outcome_msg(o));
504        let path = gens.path_of(g).map_err(fail)?;
505        let eval = gens.evaluate_built(&path).map_err(fail)?;
506        let activation =
507            gens.dry_activation(&path).map_err(|o| reject(RejectReason::ActivationUnknown, outcome_msg(o)))?;
508        let mut content = None;
509        if let Some(h) = gens.override_for(g).map_err(fail)? {
510            // Without it the swap could not write it either.
511            content = Some(gens.override_content(&h).map_err(|o| reject(RejectReason::Precondition, outcome_msg(o)))?);
512            plan.base_override = Some(live_hash);
513            plan.candidate_override = Some(h);
514        }
515        plan.candidate = Some((g, path));
516        plan.cand_eval = Some(eval);
517        plan.existing_activation = Some(activation);
518        Ok(content)
519    }
520
521    fn release(&self, plan: &Plan) {
522        if let Some(b) = &plan.built {
523            self.b.generations().release_root(&b.root);
524        }
525    }
526
527    /// What the approval token binds (§8 `ApprovalToken.bound_to`).
528    /// It names the transaction too, so each request for approval has its
529    /// own: an approval issued before a restart can never approve the change
530    /// asked for again after it, however identical (§12 step 4, S07).
531    fn binding(&self, txn: TxnId, p: &Proposal, plan: &Plan, state: &SystemState) -> Hash {
532        let cumulative = plan.facts.config_diff.as_ref().map(|d| self.session.cumulative.then(d)).unwrap_or_default();
533        let v = serde_json::json!({
534            "txn": txn.0,
535            "transition": plan.transition,
536            "staged_source": plan.staged.as_ref().map(|s| s.staged_source),
537            "candidate": plan.candidate,
538            "base": state.generation,
539            "policy": self.policy_version(),
540            "cumulative": cumulative.hash(),
541            "facts": plan.facts,
542            "expected": p.expected,
543            "revert_to": state.generation,
544            "recovery": plan.transition.recovery(),
545        });
546        Hash::of(&serde_json::to_vec(&v).expect("binding serializes"))
547    }
548
549    fn transaction(&self, p: &Proposal, plan: &Plan, state: &SystemState, defs: &UnitDefs) -> Transaction {
550        let now = self.b.clock().now();
551        // Leases and deadlines on the boot clock (§12h); `started` is shown.
552        let boot = self.b.clock().boot_now();
553        let network = plan.facts.disruption.connectivity_loss
554            || plan.facts.affected_units.iter().any(|u| u.name == "NetworkManager");
555        let prior_driver = match &plan.transition {
556            Transition::BindDriver { device, .. } => self.b.inventory().bound_driver(device).ok().flatten(),
557            _ => None,
558        };
559        Transaction {
560            id: TxnId(0),
561            state: TxnState::Prepared,
562            lease: Lease {
563                txn: TxnId(0),
564                owner: self.kernel.me.clone(),
565                epoch: 1,
566                worker_pid: None,
567                renewed: boot,
568                jobs: vec![],
569            },
570            base_source: plan.staged.as_ref().map(|s| s.base_source),
571            staged_source: plan.staged.as_ref().map(|s| s.staged_source),
572            candidate_nar: plan.built.as_ref().and_then(|b| b.nar),
573            base_override: plan.base_override,
574            candidate_override: plan.candidate_override,
575            gc_root: plan.built.as_ref().map(|b| b.root.clone()),
576            recovery: plan.transition.recovery(),
577            transition: plan.transition.clone(),
578            facts: plan.facts.clone(),
579            base: state.generation,
580            candidate: plan.candidate.clone(),
581            policy: self.policy_version(),
582            approval: None,
583            snapshot: None,
584            expected: p.expected.clone(),
585            started: now,
586            deadline: boot.plus(if network { NETWORK_DEADLINE } else { GENERATION_DEADLINE }),
587            revert_to: state.generation,
588            prior_driver,
589            prepared_again_from: None,
590            check: CheckContext {
591                candidate: plan.cand_eval.as_ref().map(manifest_of),
592                base: manifest_of(&plan.base_eval),
593                pre: defs.keys().map(|u| (u.clone(), state.status(u, defs))).collect(),
594                pre_passing: defs
595                    .keys()
596                    .map(|u| (u.clone(), state.passing_readiness(u, defs)))
597                    .filter(|(_, p)| !p.is_empty())
598                    .collect(),
599            },
600        }
601    }
602
603    /// §9 step 6: the owner's Nix-visible source must still hash to `base_source`.
604    fn stale(&self, plan: &Plan) -> bool {
605        match &plan.staged {
606            Some(s) => self.b.generations().source_hash(&self.cfg.owner).map(|h| h != s.base_source).unwrap_or(true),
607            None => false,
608        }
609    }
610
611    fn kernel_error(e: KernelError) -> Submitted {
612        match e {
613            KernelError::Journal(JournalError::Ledger(LedgerError::Exclusive { blocker, .. })) => {
614                reject(RejectReason::Other("busy".into()), format!("transaction {} is in progress", blocker.0))
615            }
616            KernelError::Journal(JournalError::Ledger(LedgerError::Interlocked { .. })) => reject(
617                RejectReason::Other("interlocked".into()),
618                "the recovery interlock is set; an administrator must run `planarian resolve`",
619            ),
620            e => Submitted::Error(format!("{e:?}")),
621        }
622    }
623
624    /// The slow part of planning `t`, to run elsewhere: `Some` for a catalog
625    /// change, whose staging, evaluation and build can take minutes. Hand
626    /// its result to `prebuilt` before submitting `t`.
627    pub fn prebuild_job(&self, t: &Transition) -> Option<PrebuildJob<B::Gens>>
628    where
629        B::Gens: Clone,
630    {
631        if !matches!(t, Transition::ApplyChange { .. }) {
632            return None;
633        }
634        let (live, live_hash) = self.b.generations().read_overrides().ok()?;
635        Some(PrebuildJob {
636            gens: self.b.generations().clone(),
637            owner: self.cfg.owner.clone(),
638            catalog: self.cfg.catalog.clone(),
639            transition: t.clone(),
640            live,
641            live_hash,
642        })
643    }
644
645    /// Admission before work (§12, R09): the checks that need no staged
646    /// source, run before anything is staged, evaluated or built. Each is a
647    /// necessary condition of `decide`'s authorization, so admission refuses
648    /// only what policy would refuse after the build.
649    pub fn admit(&self, t: &Transition, authz: &Authorization) -> Result<(), (RejectReason, &'static str)> {
650        let Transition::ApplyChange { change, .. } = t else { return Ok(()) };
651        if !matches!(authz.actor, Actor::User { .. } | Actor::Daemon | Actor::Watchdog) {
652            return Err((RejectReason::Unauthorized, "this caller cannot change the configuration"));
653        }
654        if !self.cfg.tables.catalog.contains(change) {
655            return Err((RejectReason::NotInCatalog, "that change is not in the catalog"));
656        }
657        let admin = authz.capabilities.contains(Capability::Administrator);
658        if self.kernel.journal.ledger().interlocked() && !admin {
659            return Err((
660                RejectReason::Other("interlocked".into()),
661                "the recovery interlock is set; only an administrator can change this machine until `planarian resolve`",
662            ));
663        }
664        if matches!(authz.actor, Actor::User { .. })
665            && !admin
666            && !(authz.capabilities.contains(Capability::UserControllable)
667                && self.cfg.settings.user_controllable_changes.contains(change))
668        {
669            return Err((RejectReason::Unauthorized, "that change needs an administrator"));
670        }
671        Ok(())
672    }
673
674    /// Submit a proposal. `state` and `defs` are the current observation.
675    pub fn submit(&mut self, p: Proposal, authz: Authorization, state: &SystemState, defs: &UnitDefs) -> Submitted {
676        self.submit_from(p, authz, state, defs, None)
677    }
678
679    /// §12f: submit `p` as preparing again `from`, a transaction a rollback
680    /// withdrew. The link is journaled with the new transaction
681    /// (`Transaction.prepared_again_from`). Core only: it is not in
682    /// `Proposal`, which callers build, so no caller can name a withdrawn
683    /// transaction and keep it from being prepared again.
684    pub fn submit_again(
685        &mut self,
686        p: Proposal,
687        from: TxnId,
688        authz: Authorization,
689        state: &SystemState,
690        defs: &UnitDefs,
691    ) -> Submitted {
692        self.submit_from(p, authz, state, defs, Some(from))
693    }
694
695    fn submit_from(
696        &mut self,
697        p: Proposal,
698        authz: Authorization,
699        state: &SystemState,
700        defs: &UnitDefs,
701        again: Option<TxnId>,
702    ) -> Submitted {
703        let digest = state.digest();
704        if p.observed != digest {
705            return Submitted::Rejected {
706                reason: RejectReason::Stale,
707                detail: "state changed since the proposal was made".into(),
708                digest: Some(digest),
709            };
710        }
711        self.last = None;
712        // §12c: while the recovery interlock is set, only administrators'
713        // operations and a user's own overrides run (the ledger refuses the
714        // autonomous callers itself).
715        if self.kernel.journal.ledger().interlocked()
716            && !authz.capabilities.contains(Capability::Administrator)
717            && !matches!(p.transition, Transition::SetOverride(_) | Transition::LiftOverride(_))
718        {
719            self.prebuilt = None;
720            return reject(
721                RejectReason::Other("interlocked".into()),
722                "the recovery interlock is set; only an administrator can change this machine until `planarian resolve`",
723            );
724        }
725        if let Err((reason, why)) = self.admit(&p.transition, &authz) {
726            self.prebuilt = None;
727            return reject(reason, why);
728        }
729        let pre = self.prebuilt.take();
730        let plan = match self.plan(&p, &authz, state, defs, pre) {
731            Ok(plan) => plan,
732            Err(s) => return s,
733        };
734        self.last = Some((plan.facts.clone(), plan.decision.clone()));
735        self.excluded = plan.staged.as_ref().map(|s| s.excluded.clone()).unwrap_or_default();
736        if plan.staged.is_some() {
737            // Also any added while it built: the stale check below ignores them.
738            for f in self.b.generations().untracked(&self.cfg.owner) {
739                if !self.excluded.contains(&f) {
740                    self.excluded.push(f);
741                }
742            }
743        }
744        if let Decision::Reject(r) = &plan.decision {
745            self.release(&plan);
746            return reject(r.clone(), "refused by policy");
747        }
748        // §12 step 5 (C3): preconditions are the executor's, whatever the
749        // source; checked once the caller is known to be authorized.
750        if let Some(why) = unmet(&p.transition, state, defs, &authz.actor) {
751            self.release(&plan);
752            return reject(RejectReason::Precondition, why);
753        }
754        if self.stale(&plan) {
755            self.release(&plan);
756            return reject(RejectReason::Stale, "your configuration changed while the change was being built");
757        }
758        let mut txn = self.transaction(&p, &plan, state, defs);
759        txn.prepared_again_from = again;
760        let id = match self.kernel.prepare(txn) {
761            Ok(id) => id,
762            Err(e) => {
763                self.release(&plan);
764                return Self::kernel_error(e);
765            }
766        };
767        if let Decision::Allow { tier: Tier::AskFirst, approver: class } = plan.decision {
768            let bound_to = self.binding(id, &p, &plan, state);
769            let now = self.b.clock().boot_now();
770            self.parked.insert(
771                id,
772                Parked { txn: id, proposal: p, authz, class, bound_to, issued: now, facts: plan.facts.clone() },
773            );
774            return Submitted::Parked { txn: id, approve: bound_to, facts: plan.facts };
775        }
776        self.schedule(id, plan, &p.source, state)
777    }
778
779    /// §12f: a generation candidate that takes effect only at boot takes the
780    /// boot-only path; one that would disturb a session, or any while
781    /// foreground work holds, waits in `Built` for an activation window
782    /// (`activate_waiting`); everything else runs now.
783    ///
784    /// *Rollbacks* (owner decisions, Oct 31 and Nov 2): a rollback never
785    /// waits. One the user or an Intent asks for (`Rollback`, or `planarian
786    /// undo`'s `Revert`) is its own window, also under `Foreground`; the
787    /// diagnoser's `Rollback` is a repair. The §11 floors were applied
788    /// before this, so either may have been parked for approval first; this
789    /// is reached once it is admitted and, if it asked, approved. At that
790    /// moment, and only then, every generation transaction waiting in
791    /// `Built` is withdrawn for it (`Rejected(Interrupted)`, I6); the daemon
792    /// prepares each again once the rollback is terminal. Quiet mode's
793    /// deferral of an unrelated repair rollback is the repair loop's.
794    fn schedule(&mut self, id: TxnId, plan: Plan, source: &Source, state: &SystemState) -> Submitted {
795        if !plan.transition.changes_generation() {
796            return self.run(id, &plan);
797        }
798        if plan.built.as_ref().is_some_and(|b| b.activation.boot_only()) {
799            return match self.kernel.run_boot_only(id) {
800                Ok(end) => Submitted::Finished { txn: id, state: end, followup: None },
801                Err(e) => Self::kernel_error(e),
802            };
803        }
804        let foreground = state.conditions.iter().any(|c| matches!(c, Condition::Foreground { .. }));
805        let user_asked = matches!(source, Source::User | Source::Intent { .. });
806        let rollback = match plan.transition {
807            Transition::Rollback => user_asked || matches!(source, Source::Diagnoser { .. }),
808            Transition::Revert { .. } => user_asked,
809            _ => false,
810        };
811        if rollback {
812            let waiting: Vec<TxnId> = self.waiting.keys().copied().collect();
813            for w in waiting {
814                if self.kernel.withdraw(w, id).is_ok() {
815                    self.waiting.remove(&w);
816                }
817            }
818            return self.run(id, &plan);
819        }
820        let disturbs = plan.built.as_ref().is_some_and(|b| self.disturbs_a_session(&b.activation));
821        if foreground || (disturbs && !user_asked && !self.sessions_idle()) {
822            let why = if foreground { "waiting: foreground work holds" } else { "waiting: a session is in use" };
823            return match self.kernel.wait_built(id, why) {
824                Ok(end) => {
825                    self.waiting.insert(id, (self.b.clock().boot_now(), plan));
826                    Submitted::Finished { txn: id, state: end, followup: None }
827                }
828                Err(e) => Self::kernel_error(e),
829            };
830        }
831        self.run(id, &plan)
832    }
833
834    /// The activation would restart or reload something an active user
835    /// session uses (§12f): a user unit, or one whose restart drops a session.
836    fn disturbs_a_session(&self, a: &ActivationPlan) -> bool {
837        let sessions = self.b.sessions().sessions().unwrap_or_default();
838        if !sessions.iter().any(|s| s.establishes() && s.active) {
839            return false;
840        }
841        a.stop.iter().chain(&a.restart).chain(&a.reload).any(|u| {
842            matches!(u.scope, Scope::User { .. }) || ["display-manager", "graphical-session"].contains(&u.name.as_str())
843        })
844    }
845
846    fn sessions_idle(&self) -> bool {
847        self.b.sessions().sessions().unwrap_or_default().iter().filter(|s| s.establishes() && s.active).all(|s| s.idle)
848    }
849
850    /// Activate transactions waiting in `Built` whose window has opened: no
851    /// foreground work, and the sessions idle (or the change does not disturb
852    /// one). Returns what ran.
853    pub fn activate_waiting(&mut self, state: &SystemState, defs: &UnitDefs) -> Vec<(TxnId, Submitted)> {
854        let foreground = state.conditions.iter().any(|c| matches!(c, Condition::Foreground { .. }));
855        if foreground || self.waiting.is_empty() {
856            return vec![];
857        }
858        let ready: Vec<TxnId> = self
859            .waiting
860            .iter()
861            .filter(|(_, (_, p))| {
862                !p.built.as_ref().is_some_and(|b| self.disturbs_a_session(&b.activation)) || self.sessions_idle()
863            })
864            .map(|(id, _)| *id)
865            .collect();
866        let mut out = vec![];
867        for id in ready {
868            if let Some((_, plan)) = self.waiting.remove(&id) {
869                // C2: the stale check, re-derive and re-decide on this path
870                // into Activating too.
871                if let Some(refused) = self.revalidate(id, &plan, state, defs) {
872                    out.push((id, refused));
873                    continue;
874                }
875                out.push((id, self.run(id, &plan)));
876            }
877        }
878        out
879    }
880
881    fn run(&mut self, id: TxnId, plan: &Plan) -> Submitted {
882        match self.kernel.run(id) {
883            Ok(end) => {
884                let followup = self.after(id, plan, &end);
885                Submitted::Finished { txn: id, state: end, followup }
886            }
887            Err(e) => Self::kernel_error(e),
888        }
889    }
890
891    /// Bookkeeping after a terminal state; returns a §12a write-back, if any.
892    fn after(&mut self, id: TxnId, plan: &Plan, end: &TxnState) -> Option<Proposal> {
893        if *end != TxnState::Committed {
894            return None;
895        }
896        // §12e, I25: the kernel derived the floor and recorded the override
897        // content as part of the commit; this only says so.
898        if let (Some((g, _)), Some(cand)) = (&plan.candidate, &plan.cand_eval)
899            && self.kernel.journal.ledger().security_floor == Some(*g)
900        {
901            self.floor_raised = Some((*g, cand.security_fixes(&plan.base_eval)));
902        }
903        self.session.applied.push(id);
904        if let Some(d) = &plan.facts.config_diff {
905            self.session.cumulative = self.session.cumulative.then(d);
906        }
907        if let Transition::SetRuntimeOption { path, value } = &plan.transition {
908            let (change, args) = planarian_changes::writeback(path, value)?;
909            return Some(Proposal {
910                observed: Hash::ZERO, // the daemon fills in the digest it observes
911                transition: Transition::ApplyChange { change, args },
912                expected: Expectation {
913                    units: BTreeMap::new(),
914                    generation: None,
915                    may_change: Default::default(),
916                    others_unchanged: false,
917                    settle: Settle::Duration(planarian_schema::spec::DEFAULT_SETTLE),
918                },
919                reason: format!("persist the runtime setting {path}"),
920                source: Source::User,
921            });
922        }
923        None
924    }
925
926    /// `planarian approve <hash>` (§12 step 4): re-derive, re-decide, rebind.
927    /// The caller has checked root and an interactive TTY (Phase A).
928    pub fn approve(
929        &mut self,
930        hash: &Hash,
931        approver: &Authorization,
932        state: &SystemState,
933        defs: &UnitDefs,
934    ) -> Submitted {
935        let Some(parked) = self.parked.values().find(|p| p.bound_to == *hash).cloned() else {
936            return reject(RejectReason::Other("approval".into()), "no pending change with that hash");
937        };
938        // I19: never the proposer; an Administrator class needs an administrator.
939        // A refused approval leaves the request pending for someone who may.
940        if !may_approve(approver, parked.authz.principal, parked.class) {
941            let why = if approver.principal == parked.authz.principal {
942                "you can't approve your own request"
943            } else if parked.class == ApproverClass::Administrator {
944                "this needs an administrator's approval"
945            } else {
946                "you can't approve requests"
947            };
948            return reject(RejectReason::Unauthorized, why);
949        }
950        let now = self.b.clock().boot_now();
951        if token_expired(now, parked.issued) {
952            return self.expire_one(parked.txn);
953        }
954        // §9 step 6, against the source staged when the approval was requested:
955        // an owner edit since then is Stale, not a reason to rebuild silently.
956        let staged_base = self
957            .kernel
958            .journal
959            .sync_ledger()
960            .ok()
961            .and_then(|l| l.live.get(&parked.txn).and_then(|t| t.txn.base_source));
962        if let Some(base) = staged_base
963            && self.b.generations().source_hash(&self.cfg.owner).map(|h| h != base).unwrap_or(true)
964        {
965            self.parked.remove(&parked.txn);
966            let _ = self.kernel.reject(parked.txn, RejectReason::Stale);
967            return reject(RejectReason::Stale, "your configuration changed after the approval was requested");
968        }
969        let pre = self.prebuilt.take();
970        let plan = match self.plan(&parked.proposal, &parked.authz, state, defs, pre) {
971            Ok(p) => p,
972            Err(s) => {
973                self.parked.remove(&parked.txn);
974                let _ = self.kernel.reject(parked.txn, RejectReason::Other("approval invalidated".into()));
975                return s;
976            }
977        };
978        let rebound = self.binding(parked.txn, &parked.proposal, &plan, state);
979        if rebound != parked.bound_to || !matches!(plan.decision, Decision::Allow { .. }) {
980            // Something the approval covered changed: the token is void. Withdraw
981            // this transaction and, if still allowed, park the new one.
982            self.parked.remove(&parked.txn);
983            let _ = self.kernel.reject(parked.txn, RejectReason::Other("approval invalidated".into()));
984            self.release(&plan);
985            let mut p = parked.proposal.clone();
986            p.observed = state.digest();
987            return self.submit(p, parked.authz, state, defs);
988        }
989        if self.stale(&plan) {
990            self.parked.remove(&parked.txn);
991            let _ = self.kernel.reject(parked.txn, RejectReason::Stale);
992            return reject(RejectReason::Stale, "your configuration changed after the approval was requested");
993        }
994        // One token per binding: a second approval of the same hash is a replay.
995        let mut nonce = [0u8; 16];
996        nonce.copy_from_slice(&hash.0[..16]);
997        if self.kernel.journal.ledger().tokens_used.iter().any(|(n, _)| *n == nonce) {
998            return reject(RejectReason::Other("approval".into()), "this approval was already used");
999        }
1000        // Journal records carry the wall clock (§12h); `now` here is the
1001        // boot clock, which times the token.
1002        let _ = self.kernel.journal.append(
1003            self.b.clock().now(),
1004            Record::TokenUsed {
1005                nonce,
1006                expires: parked.issued.plus(TOKEN_LIFETIME),
1007                approver: approver.principal.0,
1008                txn: parked.txn.0,
1009            },
1010        );
1011        self.parked.remove(&parked.txn);
1012        let source = parked.proposal.source.clone();
1013        self.schedule(parked.txn, plan, &source, state)
1014    }
1015
1016    fn expire_one(&mut self, txn: TxnId) -> Submitted {
1017        self.parked.remove(&txn);
1018        match self.kernel.reject(txn, RejectReason::ApprovalExpired) {
1019            Ok(s) => Submitted::Finished { txn, state: s, followup: None },
1020            Err(e) => Self::kernel_error(e),
1021        }
1022    }
1023
1024    /// Tokens expire after 24 h (§12 step 4); the parked transaction ends Rejected.
1025    pub fn expire(&mut self) -> Vec<TxnId> {
1026        let now = self.b.clock().boot_now();
1027        let old: Vec<TxnId> = self.parked.values().filter(|p| token_expired(now, p.issued)).map(|p| p.txn).collect();
1028        for t in &old {
1029            self.expire_one(*t);
1030        }
1031        old
1032    }
1033
1034    /// The transaction waits in `Built` for an activation window (§12f).
1035    pub fn is_waiting(&self, txn: TxnId) -> bool {
1036        self.waiting.contains_key(&txn)
1037    }
1038
1039    /// When the transaction began waiting in `Built` (`CLOCK_BOOTTIME`).
1040    pub fn waiting_since(&self, txn: TxnId) -> Option<Timestamp> {
1041        self.waiting.get(&txn).map(|(since, _)| *since)
1042    }
1043
1044    /// §12f, I12: a change withdrawn for a rollback and prepared again keeps
1045    /// the parking limit it had, from the earlier of the two times.
1046    pub fn keep_waiting_since(&mut self, txn: TxnId, since: Timestamp) {
1047        if let Some((s, _)) = self.waiting.get_mut(&txn) {
1048            *s = (*s).min(since);
1049        }
1050    }
1051
1052    /// §12f, I12: parking in `Built` is bounded. A transaction that has
1053    /// waited longer than the parking limit is `Cancelled(Daemon)`; returns
1054    /// each one with the change it would have made, for the notice.
1055    pub fn expire_waiting(&mut self) -> Vec<(TxnId, Transition)> {
1056        let now = self.b.clock().boot_now();
1057        let old: Vec<TxnId> =
1058            self.waiting.iter().filter(|(_, (since, _))| now > since.plus(PARKING_LIMIT)).map(|(id, _)| *id).collect();
1059        let mut out = vec![];
1060        for id in old {
1061            if let Some((_, plan)) = self.waiting.remove(&id)
1062                && self.kernel.cancel(id, Actor::Daemon).is_ok()
1063            {
1064                out.push((id, plan.transition));
1065            }
1066        }
1067        out
1068    }
1069
1070    /// A user withdraws a parked proposal (§12 step 5).
1071    pub fn cancel(&mut self, txn: TxnId, by: Actor) -> Submitted {
1072        if self.parked.remove(&txn).is_none() {
1073            return reject(RejectReason::Other("cancel".into()), "no such pending change");
1074        }
1075        match self.kernel.cancel(txn, by) {
1076            Ok(s) => Submitted::Finished { txn, state: s, followup: None },
1077            Err(e) => Self::kernel_error(e),
1078        }
1079    }
1080}
1081
1082pub mod residue;
1083
1084#[cfg(kani)]
1085mod kani;
1086#[cfg(test)]
1087mod tests;