Skip to main content

cordis/
loader.rs

1//! Declarative loader with config reconciliation (Phase 3).
2//!
3//! `Loader` reconciles a desired [`EntryTree`] against the current tree and
4//! emits per-entry [`LoaderAction`]s.  This replaces the ad-hoc `notify` + `ArcSwap`
5//! hot-reload previously scattered across `AresConfigManager`, `DynamicConfigManager`,
6//! `RuntimeToolRegistry::start_background_reload`, `ProviderRegistry`, and
7//! `NvidiaCatalogCache` (see `docs/cordis-mapping.md` §11).
8//! The unified hot-reload path is now `ReflectService::notify(TypeId)` which
9//! BFS-walks `dependents: RwLock<HashMap<TypeId, Vec<FiberId>>>` and triggers
10//! `Fiber::refresh` via `watch` channels (`notifiers: RwLock<HashMap<TypeId, watch::Sender<()>>>`)
11//! — polling via `RuntimeToolRegistry::start_background_reload` 60s `interval` is deprecated:
12//! `// REMOVED: polling fallback retained for one release then delete` (see `ReflectService` in `cordis`).
13//!
14//! Persistence is to `config/entries.json` (JSON) or, when the `toon` feature
15//! is enabled, `config/cordis-entries.toon` via `toon-format 0.4.1`.  It never
16//! touches `ares.toml` which remains a symlink to `/opt/ares-config/ares.toml`
17//! — the loader writes to `config/entries.json` / `config/cordis-entries.toon`
18//! separate from `ares.toml`.
19
20use std::any::TypeId;
21use std::collections::{BTreeSet, HashMap, HashSet};
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
24use std::sync::Arc;
25
26/// Loader-owned operating state for the fibers it started.
27///
28/// Two responsibilities live here:
29///
30/// * **Self-kill window** ([`Self::in_loader_window`]): the loader raises
31///   this flag around every reconcile-driven disposal (`Retire` actions,
32///   rebuild swaps). A [`crate::Fiber::subscribe_state`] observer registered
33///   by [`Loader::watch_entry_fiber`] consults it when a tracked fiber is
34///   disposed — a dispose that ran OUTSIDE a loader window means the plugin
35///   killed its own registration, and the entry is persisted `disabled =
36///   true` (via [`SelfKillPersistence`]) so restarts do not resurrect a
37///   crash-looping plugin.
38/// * **Apply count**: [`Self::apply_count(id)`] counts completed factory
39///   applications per entry id, incremented from
40///   [`Loader::instantiate_entry`]. Config-only patches must NOT bump it —
41///   that is exactly what the no-restart patch tests assert against.
42///
43/// Provided as a Service lazily by the loader paths that need it; absent on
44/// library deployments, where every accessor degrades to a safe no-op.
45#[derive(Clone, Default)]
46pub struct LoaderOps {
47    inner: Arc<LoaderOpsInner>,
48}
49
50#[derive(Default)]
51struct LoaderOpsInner {
52    /// `true` while the loader itself drives disposals (reconcile windows).
53    in_loader_window: AtomicBool,
54    /// Completed factory applications per entry id.
55    apply_counts: std::sync::Mutex<HashMap<String, u64>>,
56    /// Self-kill persistence sink; set via [`Self::enable_self_kill_persistence`].
57    persistence: std::sync::Mutex<Option<Arc<SelfKillPersistence>>>,
58    /// Entry ids already persisted disabled (dedup so repeated observer
59    /// firings write the file at most once per entry).
60    persisted_disabled: std::sync::Mutex<BTreeSet<String>>,
61}
62
63impl Service for LoaderOps {}
64
65impl LoaderOps {
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    fn enter_loader_window(&self) -> LoaderWindowGuard {
71        self.inner.in_loader_window.store(true, Ordering::SeqCst);
72        LoaderWindowGuard(self.inner.clone())
73    }
74
75    fn in_loader_window(&self) -> bool {
76        self.inner.in_loader_window.load(Ordering::SeqCst)
77    }
78
79    fn record_apply(&self, id: &str) {
80        *self
81            .inner
82            .apply_counts
83            .lock()
84            .unwrap_or_else(std::sync::PoisonError::into_inner)
85            .entry(id.to_string())
86            .or_insert(0) += 1;
87    }
88
89    /// Completed factory applications for one entry id.
90    pub fn apply_count(&self, id: &str) -> u64 {
91        self.inner
92            .apply_counts
93            .lock()
94            .unwrap_or_else(std::sync::PoisonError::into_inner)
95            .get(id)
96            .copied()
97            .unwrap_or(0)
98    }
99
100    /// Install the self-kill persistence sink (entries file path + format).
101    pub fn enable_self_kill_persistence(&self, path: PathBuf, toon_format: bool) {
102        let mut sink = self
103            .inner
104            .persistence
105            .lock()
106            .unwrap_or_else(std::sync::PoisonError::into_inner);
107        *sink = Some(Arc::new(SelfKillPersistence { path, toon_format }));
108        drop(sink);
109        // Drop any dedup state from a previous sink so re-enabling can fire again.
110        self.inner
111            .persisted_disabled
112            .lock()
113            .unwrap_or_else(std::sync::PoisonError::into_inner)
114            .clear();
115    }
116
117    fn self_kill_persistence(&self) -> Option<Arc<SelfKillPersistence>> {
118        self.inner
119            .persistence
120            .lock()
121            .unwrap_or_else(std::sync::PoisonError::into_inner)
122            .clone()
123    }
124
125    /// Persist `disabled = true` for `id` onto the entries file exactly once.
126    fn persist_self_kill(&self, id: &str) {
127        if !self
128            .inner
129            .persisted_disabled
130            .lock()
131            .unwrap_or_else(std::sync::PoisonError::into_inner)
132            .insert(id.to_string())
133        {
134            return;
135        }
136        let Some(persistence) = self.self_kill_persistence() else {
137            tracing::warn!(entry_id = %id,
138                "Loader: plugin disposed itself outside a loader window but no entries \
139                 program is configured; restart would resurrect it");
140            return;
141        };
142        match persistence.persist_disabled(id) {
143            Ok(()) => tracing::warn!(entry_id = %id,
144                "Loader: plugin disposed itself outside a loader window; persisted disabled=true"),
145            Err(e) => {
146                // Allow a later dispose attempt of the same entry to retry.
147                self.inner
148                    .persisted_disabled
149                    .lock()
150                    .unwrap_or_else(std::sync::PoisonError::into_inner)
151                    .remove(id);
152                tracing::error!(entry_id = %id, error = %e,
153                    "Loader: failed to persist disabled=true for self-disposed entry");
154            }
155        }
156    }
157}
158
159/// RAII marker for a loader-driven disposal window: construction raises the
160/// operating flag, drop lowers it. The flag lives on the shared
161/// [`LoaderOpsInner`] because state observers run inline on whatever thread
162/// drove the transition.
163struct LoaderWindowGuard(Arc<LoaderOpsInner>);
164
165impl Drop for LoaderWindowGuard {
166    fn drop(&mut self) {
167        self.0.in_loader_window.store(false, Ordering::SeqCst);
168    }
169}
170
171/// Persistence sink for self-kill detection: rewrites the entries program
172/// with `disabled = true` on one entry through the existing atomic writers
173/// ([`EntryTree::save_to_toml_file`] / [`EntryTree::save_to_file`]).
174struct SelfKillPersistence {
175    path: PathBuf,
176    toon_format: bool,
177}
178
179impl SelfKillPersistence {
180    fn persist_disabled(&self, id: &str) -> Result<(), CordisError> {
181        let mut tree = if self.toon_format {
182            Loader::load_from_file(&self.path)
183        } else {
184            EntryTree::load_from_json_path(&self.path)
185        }?;
186        let Some(entry) = tree.0.iter_mut().find(|e| e.id == id) else {
187            return Ok(()); // Entry no longer declared: nothing to persist.
188        };
189        if entry.disabled {
190            return Ok(()); // Already disabled on disk; idempotent.
191        }
192        entry.disabled = true;
193        if self.toon_format {
194            tree.save_to_toml_file(&self.path)
195        } else {
196            tree.save_to_file(
197                self.path
198                    .to_str()
199                    .ok_or_else(|| CordisError::Configuration("non-utf8 entries path".into()))?,
200            )
201        }
202    }
203}
204
205/// Process-global monotonic nonce for save temp-file names: two concurrent
206/// saves in one process never collide on the same sibling temp (a bare pid
207/// suffix made two racing saves unlink each other's temp mid-flight).
208static SAVE_TMP_NONCE: AtomicU64 = AtomicU64::new(0);
209
210fn next_save_nonce() -> u64 {
211    SAVE_TMP_NONCE.fetch_add(1, Ordering::Relaxed)
212}
213
214/// Atomic single-file persistence for loader configs.
215///
216/// Creates the parent directory when missing, writes `bytes` to a sibling
217/// temp named `{file}.tmp-{pid}-{nonce}`, then renames it over `path`. A
218/// crash mid-write leaves the previous file intact; the temp is removed on
219/// failure so no `.tmp-*` residue accumulates. The pid+nonce suffix keeps
220/// concurrent saves (threads, double-dispatch) from sharing one temp name.
221fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), CordisError> {
222    if let Some(parent) = path.parent() {
223        if !parent.as_os_str().is_empty() {
224            std::fs::create_dir_all(parent)
225                .map_err(|e| CordisError::Configuration(e.to_string()))?;
226        }
227    }
228    let name = path
229        .file_name()
230        .and_then(|s| s.to_str())
231        .unwrap_or("entries");
232    let tmp = path.with_file_name(format!(
233        "{name}.tmp-{}-{}",
234        std::process::id(),
235        next_save_nonce()
236    ));
237    if let Err(e) = std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, path)) {
238        let _ = std::fs::remove_file(&tmp);
239        return Err(CordisError::Configuration(e.to_string()));
240    }
241    Ok(())
242}
243
244use serde::{Deserialize, Serialize};
245
246use crate::{CordisError, LoaderJournal, Service};
247
248/// JSON intercept overlay from [`Entry::intercept`], readable via `ctx.get`.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct EntryIntercept(pub HashMap<String, serde_json::Value>);
251
252impl Service for EntryIntercept {
253    fn name(&self) -> &'static str {
254        "entry_intercept"
255    }
256}
257
258/// TOML wrapper struct for `[[entry]]` array deserialization.
259#[derive(Debug, Deserialize, Serialize)]
260struct TomlEntries {
261    #[serde(default)]
262    entry: Vec<Entry>,
263}
264
265/// Canonical on-disk location for the declarative entry tree (JSON).
266pub const ENTRIES_PATH: &str = "config/entries.json";
267
268/// Alternative on-disk location when `toon-format 0.4.1` is used (`toon` feature).
269/// Kept separate from `ares.toml` (which is a symlink to `/opt/ares-config/ares.toml`);
270/// the loader never writes to `ares.toml` — see `config/entries.json` vs `ares.toml` invariant.
271pub const CORDIS_ENTRIES_TOON_PATH: &str = "config/cordis-entries.toon";
272
273/// A single declarative loader entry.
274///
275/// Each entry describes one plugin instance: its unique `id`, the `plugin`
276/// type label, opaque JSON `config`, and optional spatial modifiers
277/// (`isolate` realm label, `intercept` overrides).  `disabled` gates whether
278/// the fiber is `Retire`d or `Begin`n.
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct Entry {
281    pub id: String,
282    pub plugin: String,
283    #[serde(default)]
284    pub config: serde_json::Value,
285    #[serde(default)]
286    pub disabled: bool,
287    #[serde(default)]
288    pub isolate: Option<String>,
289    #[serde(default)]
290    pub intercept: HashMap<String, serde_json::Value>,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub position: Option<EntryPosition>,
293}
294
295impl Default for Entry {
296    fn default() -> Self {
297        Self {
298            id: String::new(),
299            plugin: String::new(),
300            config: serde_json::Value::Null,
301            disabled: false,
302            isolate: None,
303            intercept: HashMap::new(),
304            position: None,
305        }
306    }
307}
308
309/// Hierarchy placement for one [`Entry`]: an optional parent id plus an
310/// ordering index among that parent's children (`None` parent = tree root).
311///
312/// The parent link is advisory structure for admin surfaces (tree rendering,
313/// [`EntryTree::move_entry`]); it never affects reconciliation, which keys on
314/// ids alone. Descendant naming follows the `{ancestor}:` path convention:
315/// every child of `grp` is expected to carry an id prefixed `grp:`, so a
316/// subtree rename can mechanically remap the whole namespace (see
317/// [`EntryTree::move_entry`]).
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
319pub struct EntryPosition {
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub parent: Option<String>,
322    #[serde(default)]
323    pub position: usize,
324}
325
326/// Partial update for one [`Entry`] — the request body of
327/// `PATCH /admin/cordis/entries/{id}`.
328///
329/// Every field is optional: only the fields present in the request are
330/// copied onto the target entry by [`EntryUpdate::apply_to`]; omitted
331/// fields are left untouched, so `{}` is a validated no-op. An explicit
332/// `null` `config` clears back to the default (the admin layer normalizes
333/// `Null` configs to `{}` before persistence, matching PUT behavior).
334#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
335pub struct EntryUpdate {
336    pub config: Option<serde_json::Value>,
337    pub disabled: Option<bool>,
338    pub isolate: Option<String>,
339    pub intercept: Option<std::collections::BTreeMap<String, serde_json::Value>>,
340    /// Move directive: the outer `Option` marks field presence; the inner
341    /// one is the target parent (`None` = move to tree root). Present
342    /// `parent` / `position` fields drive [`EntryTree::move_entry`] BEFORE
343    /// the remaining fields apply, so one PATCH can relocate and reconfigure
344    /// in a single call. They are consumed by the admin layer and never
345    /// copied onto the entry by [`EntryUpdate::apply_to`].
346    pub parent: Option<Option<String>>,
347    pub position: Option<usize>,
348}
349
350impl EntryUpdate {
351    /// Apply only the provided fields onto `entry`; every other field keeps
352    /// its current value. `id` / `plugin` are deliberately not patchable —
353    /// changing them is a rebuild, expressed by DELETE + PUT.
354    pub fn apply_to(&self, entry: &mut Entry) {
355        if let Some(config) = &self.config {
356            entry.config = config.clone();
357        }
358        if let Some(disabled) = self.disabled {
359            entry.disabled = disabled;
360        }
361        if let Some(isolate) = &self.isolate {
362            entry.isolate = Some(isolate.clone());
363        }
364        if let Some(intercept) = &self.intercept {
365            entry.intercept = intercept
366                .iter()
367                .map(|(k, v)| (k.clone(), v.clone()))
368                .collect();
369        }
370    }
371}
372
373/// Ordered set of [`Entry`]s — the declarative desired state.
374#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
375pub struct EntryTree(pub Vec<Entry>);
376
377impl EntryTree {
378    pub fn new(entries: Vec<Entry>) -> Self {
379        Self(entries)
380    }
381
382    pub fn len(&self) -> usize {
383        self.0.len()
384    }
385
386    pub fn is_empty(&self) -> bool {
387        self.0.is_empty()
388    }
389
390    pub fn iter(&self) -> std::slice::Iter<'_, Entry> {
391        self.0.iter()
392    }
393
394    /// Serialize to pretty JSON (for `config/entries.json`).
395    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
396        serde_json::to_string_pretty(self)
397    }
398
399    /// Deserialize from JSON string with round-trip guarantee via `serde_json`.
400    pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
401        serde_json::from_str(s)
402    }
403
404    /// Persist to `path` (defaults to [`ENTRIES_PATH`]) as JSON.
405    ///
406    /// Atomic: the bytes land via a sibling temp file + rename
407    /// ([`write_atomic`]), so a crash mid-write leaves the previous config
408    /// intact and no `.tmp-*` residue survives either outcome. The parent
409    /// directory is created when missing.
410    /// When the `toon` feature is enabled callers may use [`CORDIS_ENTRIES_TOON_PATH`]
411    /// with `toon-format` encoding (see comment in `save_toon`).
412    pub fn save_to_file(&self, path: &str) -> Result<(), CordisError> {
413        let json = serde_json::to_string_pretty(self)
414            .map_err(|e| CordisError::Configuration(e.to_string()))?;
415        write_atomic(Path::new(path), json.as_bytes())
416    }
417
418    pub fn load_from_file(path: &str) -> Result<Self, CordisError> {
419        let data =
420            std::fs::read_to_string(path).map_err(|e| CordisError::Configuration(e.to_string()))?;
421        serde_json::from_str(&data).map_err(|e| CordisError::Configuration(e.to_string()))
422    }
423
424    /// [`Self::load_from_file`] taking a `Path` — the shape the loader's
425    /// self-kill persistence sink needs for JSON programs.
426    pub fn load_from_json_path(path: &Path) -> Result<Self, CordisError> {
427        let data = std::fs::read_to_string(path).map_err(|e| {
428            CordisError::Configuration(format!("failed to read {}: {}", path.display(), e))
429        })?;
430        serde_json::from_str(&data).map_err(|e| CordisError::Configuration(e.to_string()))
431    }
432
433    /// Serialize the tree to TOML, preserving any leading comment header
434    /// (lines starting with '#', plus blank lines) already present in the
435    /// existing file. Comments cannot survive serde round-trips, so they
436    /// are captured verbatim from the current file content and prepended
437    /// to the regenerated body.
438    pub fn save_to_toml_file(&self, path: &Path) -> Result<(), CordisError> {
439        let mut header = String::new();
440        if let Ok(existing) = std::fs::read_to_string(path) {
441            for line in existing.lines() {
442                if line.starts_with('#') || line.trim().is_empty() {
443                    header.push_str(line);
444                    header.push('\n');
445                } else {
446                    break;
447                }
448            }
449        }
450        let body = toml::to_string_pretty(&TomlEntries {
451            entry: self.0.clone(),
452        })
453        .map_err(|e| CordisError::Configuration(e.to_string()))?;
454        // Same atomic temp+rename persistence as the JSON path, sharing the
455        // pid+nonce temp naming so concurrent saves never collide.
456        write_atomic(path, format!("{header}{body}").as_bytes())
457    }
458
459    // --- Hierarchy (parent / position) -----------------------------------
460
461    /// Separator of the hierarchical id namespace: a child of `grp` carries
462    /// an id prefixed `grp:`; the whole descendant namespace remaps under a
463    /// subtree rename ([`Self::move_entry`]).
464    pub const ID_SEP: char = ':';
465
466    /// Leaf segment of a hierarchical id (`"a:b:c"` → `"c"`).
467    fn leaf_id(id: &str) -> &str {
468        id.rsplit(Self::ID_SEP).next().unwrap_or(id)
469    }
470
471    /// Ids of every entry whose stored [`EntryPosition::parent`] is `parent`
472    /// (`None` = roots), ordered by stored position with tree order as the
473    /// stable tiebreak.
474    pub fn children_ids(&self, parent: Option<&str>) -> Vec<String> {
475        self.0
476            .iter()
477            .filter(|e| e.position.as_ref().and_then(|p| p.parent.as_deref()) == parent)
478            .filter(|e| e.id != parent.unwrap_or(""))
479            .map(|e| e.id.clone())
480            .collect()
481    }
482
483    /// Every id in the subtree rooted at `id` (excluding `id` itself): the
484    /// union of the `{id}:*` id-prefix namespace and parent-pointer
485    /// reachability, in tree order.
486    pub fn subtree_ids(&self, id: &str) -> Vec<String> {
487        let mut out: Vec<String> = Vec::new();
488        let mut frontier: Vec<String> = vec![id.to_string()];
489        while let Some(front) = frontier.pop() {
490            for e in &self.0 {
491                let linked = e.id.starts_with(&format!("{front}{}", Self::ID_SEP))
492                    || e.position
493                        .as_ref()
494                        .and_then(|p| p.parent.as_deref())
495                        == Some(front.as_str());
496                if linked && !out.contains(&e.id) && e.id != id {
497                    out.push(e.id.clone());
498                    frontier.push(e.id.clone());
499                }
500            }
501        }
502        out
503    }
504
505    /// Pure structural move of the subtree rooted at `id` under `target`
506    /// (`None` = tree root), inserting it at `position` among the target's
507    /// children.
508    ///
509    /// Because the id namespace is hierarchical, relocating renames: the
510    /// moved entry becomes `{target}:{leaf}` (or `{leaf}` when moving to the
511    /// root), and EVERY descendant `{id}:…` remaps to `{new_id}:…`. Parent
512    /// pointers inside the subtree follow their renamed owners. Returns the
513    /// old → new pairs in subtree order (moved entry first).
514    ///
515    /// Refusals (the tree is left untouched):
516    /// - unknown `id` or `target`;
517    /// - moving an entry under ITSELF or one of its own descendants;
518    /// - any renamed id colliding with an entry outside the moved subtree.
519    pub fn move_entry(
520        &mut self,
521        id: &str,
522        target: Option<&str>,
523        position: usize,
524    ) -> Result<Vec<(String, String)>, String> {
525        if id.is_empty() {
526            return Err("cannot move the empty id".to_string());
527        }
528        if !self.0.iter().any(|e| e.id == id) {
529            return Err(format!("no such entry '{id}'"));
530        }
531        if let Some(t) = target {
532            if t == id {
533                return Err(format!(
534                    "cannot move entry '{id}' under itself"
535                ));
536            }
537            if !self.0.iter().any(|e| e.id == t) {
538                return Err(format!("no such entry '{t}'"));
539            }
540            if self.subtree_ids(id).iter().any(|d| d == t) {
541                return Err(format!(
542                    "cannot move entry '{id}' under its own descendant '{t}'"
543                ));
544            }
545        }
546
547        // Compute the rename map over the whole moved subtree.
548        let new_root = match target {
549            Some(t) => format!("{t}{}{}", Self::ID_SEP, Self::leaf_id(id)),
550            None => Self::leaf_id(id).to_string(),
551        };
552        let mut renames: Vec<(String, String)> =
553            vec![(id.to_string(), new_root.clone())];
554        for desc in self.subtree_ids(id) {
555            let new_id = desc.replacen(&format!("{id}{}", Self::ID_SEP), &format!("{new_root}{}", Self::ID_SEP), 1);
556            renames.push((desc, new_id));
557        }
558
559        // Collision check: each new id must be free outside the moved set.
560        for (old, new) in &renames {
561            let in_subtree = renames.iter().any(|(o, _)| o == new);
562            if !in_subtree {
563                if let Some(existing) = self.0.iter().find(|e| &e.id == new) {
564                    return Err(format!(
565                        "cannot rename '{old}' to '{new}': id already used by plugin '{}'",
566                        existing.plugin
567                    ));
568                }
569            }
570        }
571        let map: HashMap<&str, &str> =
572            renames.iter().map(|(o, n)| (o.as_str(), n.as_str())).collect();
573
574        // Apply renames + pointer remaps in one pass.
575        for e in self.0.iter_mut() {
576            if let Some(n) = map.get(e.id.as_str()) {
577                e.id = (*n).to_string();
578            }
579            if let Some(pos) = e.position.as_mut() {
580                if let Some(p) = pos.parent.as_deref() {
581                    if let Some(n) = map.get(p) {
582                        pos.parent = Some((*n).to_string());
583                    }
584                }
585            }
586        }
587
588        // Re-point the moved root and place it at the requested position.
589        let moved = self
590            .0
591            .iter_mut()
592            .find(|e| e.id == new_root)
593            .expect("renamed root just written");
594        let slot = moved.position.get_or_insert_with(EntryPosition::default);
595        slot.parent = target.map(str::to_string);
596        slot.position = position;
597        Ok(renames)
598    }
599}
600
601/// Per-entry diff emitted by [`Loader::reconcile`].
602///
603/// Dispatch per §13:
604/// - `id` / `plugin` change → `RebuildFiber`
605/// - `config` change → `UpdateConfig`
606/// - `disabled` toggle → `Retire` / `Begin`
607/// - `isolate` / `intercept` change → `RebuildFiber` (spatial scope change)
608#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
609pub enum LoaderAction {
610    RebuildFiber {
611        id: String,
612        plugin: String,
613    },
614    UpdateConfig {
615        id: String,
616        new_config: serde_json::Value,
617    },
618    Retire {
619        id: String,
620    },
621    Begin {
622        id: String,
623    },
624}
625
626/// Declarative loader — diffs `EntryTree`s incrementally.
627///
628/// Confluence (Thm 73) correctness condition: regardless of entry application
629/// order, the quiescent context must equal static assembly of the final
630/// `EntryTree`.  `reconcile` is the field-level diff that callers use to
631/// drive `Fiber::refresh` / `Fiber::reload` without manual wiring.
632///
633/// Persisted to [`ENTRIES_PATH`] (`config/entries.json`) or
634/// [`CORDIS_ENTRIES_TOON_PATH`] (`config/cordis-entries.toon` via
635/// `toon-format 0.4.1` when `toon` feature is enabled).  Never writes
636/// `ares.toml`.
637#[derive(Debug, Default, Clone)]
638pub struct Loader;
639
640impl Service for Loader {}
641
642/// Outcome of [`Loader::move_entry`].
643#[derive(Debug, Clone, PartialEq, Eq)]
644pub struct MoveOutcome {
645    /// Old → new id for the moved entry and every renamed descendant, in
646    /// subtree order (moved entry first). Empty when nothing moved.
647    pub renamed: Vec<(String, String)>,
648    /// `true` when the contexts-equivalence gate kept every live fiber
649    /// untouched (pure structural move); `false` when the gate fell back to a
650    /// full reconcile apply (dispose + re-create of renamed entries).
651    pub noop: bool,
652}
653
654impl Loader {
655    /// Relocate the subtree rooted at `id` under `target` (`None` = root) at
656    /// `position`, then make the LIVE kernel agree with the moved tree.
657    ///
658    /// Validation and the rename cascade are [`EntryTree::move_entry`] (pure,
659    /// error → tree untouched). Fiber handling then goes through the
660    /// contexts-equivalence gate:
661    ///
662    /// * **Equivalent composition** (same multiset of plugin/config/disabled/
663    ///   isolate across both trees — what every pure structural move is):
664    ///   NOOP. Every journaled record is re-keyed old → new with its fiber id
665    ///   PRESERVED (the existing registration fiber handle is refreshed in
666    ///   place — epoch label + ledger annotation — never disposed or
667    ///   re-created), so consumers keep resolving the same live instances.
668    /// * **Different composition** (mixed edits rode along): fall back to the
669    ///   standard staged [`Self::apply`] reconcile, which restarts renamed
670    ///   entries through Retire + Begin.
671    ///
672    /// The shared [`CurrentEntries`] view (when provided) is synced to the
673    /// post-move tree either way, so a follow-up disk reload diffs cleanly
674    /// instead of seeing phantom Retire/Begin pairs for the renames.
675    pub async fn move_entry(
676        ctx: &Arc<crate::Context>,
677        current: &mut EntryTree,
678        journal: &crate::LoaderJournal,
679        id: &str,
680        target: Option<&str>,
681        position: usize,
682    ) -> Result<MoveOutcome, CordisError> {
683        let before = current.clone();
684        let renamed = current
685            .move_entry(id, target, position)
686            .map_err(CordisError::Configuration)?;
687
688        let noop = Self::composition_equivalent(&before, current);
689        if noop {
690            for (old, new) in &renamed {
691                // Re-key the journal record, keeping plugin/config/generation
692                // AND the tracked fiber id — identity preservation is the
693                // whole point of the noop path.
694                let Some(record) = journal.rename(old, new) else {
695                    continue;
696                };
697                let Some(fid) = record.fiber_id else {
698                    continue;
699                };
700                // Refresh the EXISTING fiber handle in place: same Arc<Fiber>,
701                // same registration id, only the ownership label moves.
702                if let Some(fiber) = ctx
703                    .get::<crate::RegistryService>()
704                    .and_then(|rs| rs.get_fiber(fid))
705                {
706                    fiber.set_epoch(new.clone());
707                }
708                if let Some(ledger) = ctx.get::<crate::cycles::CycleLedger>() {
709                    ledger.note_entry(fid, new);
710                }
711            }
712        } else {
713            let desired = current.clone();
714            Self::apply(ctx, current, &desired, journal).await;
715        }
716
717        if let Some(shared) = ctx.get::<crate::CurrentEntries>() {
718            if let Ok(mut tree) = shared.tree.lock() {
719                *tree = current.clone();
720            }
721        }
722        Ok(MoveOutcome { renamed, noop })
723    }
724
725    /// Contexts-equivalence gate: `true` when both trees declare the SAME
726    /// effective service composition — identical multisets of
727    /// `(plugin, config, disabled, isolate)` ignoring ids and positions.
728    fn composition_equivalent(a: &EntryTree, b: &EntryTree) -> bool {
729        let signature = |tree: &EntryTree| {
730            let mut sig: Vec<(String, String, bool, Option<String>)> = tree
731                .0
732                .iter()
733                .map(|e| {
734                    (
735                        e.plugin.clone(),
736                        serde_json::to_string(&e.config).unwrap_or_default(),
737                        e.disabled,
738                        e.isolate.clone(),
739                    )
740                })
741                .collect();
742            sig.sort();
743            sig
744        };
745        signature(a) == signature(b)
746    }
747}
748
749/// Shared, mutable view of the last successfully applied entry tree plus the
750/// file it was loaded from. Provided as a Service so the file watcher, admin
751/// reload endpoint, and boot all operate on the same state.
752#[derive(Clone)]
753pub struct CurrentEntries {
754    pub tree: std::sync::Arc<std::sync::Mutex<EntryTree>>,
755    pub path: std::path::PathBuf,
756}
757
758impl Service for CurrentEntries {}
759
760/// Optional boot-time hook letting the loader fill empty entry configs before
761/// later entries instantiate. The server binary provides an implementation
762/// backed by its Overlay; library users may provide their own or none.
763pub trait EntryConfigFiller: Send + Sync {
764    fn fill_empty_entry_configs(&self, tree: &mut EntryTree);
765}
766
767/// Service wrapper so `Context::get` can resolve the hook.
768#[derive(Clone)]
769pub struct EntryConfigFillerHandle(pub std::sync::Arc<dyn EntryConfigFiller>);
770
771impl Service for EntryConfigFillerHandle {}
772
773/// Per-action outcome reported by [`Loader::apply`].
774#[derive(Debug, Clone)]
775pub struct AppliedAction {
776    pub id: String,
777    /// `"begin" | "update-config" | "retire" | "rebuild-fiber"`
778    pub action: &'static str,
779    pub status: Result<(), String>,
780    /// Verified hot-swap outcome for `rebuild-fiber` actions: `true` when the
781    /// replacement plugin was applied out-of-band and returned `Ok` before the
782    /// old fiber was retired. Non-rebuild actions report `true`.
783    pub verified: bool,
784}
785
786/// Process-wide in-flight provider-update ledger (`fiber id` → count).
787///
788/// C2 cascade batching: entries are inserted by [`Loader::drive_fiber_update`]
789/// for the duration of one live re-apply and consulted inside the kernel's
790/// refresh path, so concurrent config patches against one provider produce a
791/// SINGLE dependency cascade after completion instead of one wave per patch.
792static CASCADE_INFLIGHT: std::sync::LazyLock<std::sync::Mutex<HashMap<u64, u64>>> =
793    std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
794
795impl Loader {
796    /// Reconcile `current` toward `desired`, executing every action for real.
797    ///
798    /// Unlike [`Loader::execute_action`] (kept for compatibility), this
799    /// orchestrator resolves entry payloads from `desired` so `Begin` and
800    /// `RebuildFiber` instantiate with the entry's actual config (fixing
801    /// the log-only/`Value::Null` behavior), and `Retire` disposes the live
802    /// fiber recorded in `journal`.
803    ///
804    /// Two-phase STAGED apply: phase one constructs and verifies every
805    /// replacement candidate without mutating any live entry (config
806    /// pre-flight trials, entry resolution); phase two applies the verified
807    /// candidates in dependency order. On the first failing verification the
808    /// batch aborts BEFORE any mutation — nothing has been touched, so no
809    /// rollback is needed. On a failure DURING phase two, every
810    /// already-applied change is reverted (config restored, rebuilt fibers
811    /// disposed) so the live tree serves the originals; the failing step's
812    /// [`AppliedAction`] reports `Err` naming it.
813    ///
814    /// Failure policy: on any failure `current` is left unchanged so a retry
815    /// re-diffs cleanly. Returns per-action outcomes.
816    ///
817    /// Config-only patches on Active fibers go through the existing update
818    /// path ([`Self::trial_config_verified`] pre-flight + `Fiber::update`)
819    /// instead of stop+start — the factory runs only inside the scratch
820    /// trial, so apply counts stay flat across pure config changes.
821    pub async fn apply(
822        ctx: &Arc<crate::Context>,
823        current: &mut EntryTree,
824        desired: &EntryTree,
825        journal: &crate::LoaderJournal,
826    ) -> Vec<AppliedAction> {
827        use apply_staged::Staged;
828
829        let loader = Loader::new();
830        let actions = loader.reconcile(current, desired);
831        let ops = ctx.get::<LoaderOps>();
832        // Phase 1 — STAGE: resolve entries and verify every candidate. No
833        // live entry mutates here; failures abort the batch untouched.
834        let mut staged: Vec<Staged> = Vec::with_capacity(actions.len());
835        let mut results: Vec<AppliedAction> = Vec::with_capacity(actions.len());
836
837        for action in &actions {
838            match action {
839                LoaderAction::Retire { id } => {
840                    staged.push(Staged::Retire { id: id.clone() });
841                }
842                LoaderAction::UpdateConfig { id, new_config } => {
843                    let old_config = current
844                        .0
845                        .iter()
846                        .find(|e| e.id == *id)
847                        .map(|e| e.config.clone())
848                        .unwrap_or(serde_json::Value::Null);
849                    // Pre-flight: trial the NEW config through the same
850                    // scratch-context machinery the verified hot-swap uses,
851                    // BEFORE staging the mutation. A failing factory leaves
852                    // the old provider serving and fails the action; a
853                    // passing trial discards the candidate (the live fiber
854                    // re-applies below).
855                    if let Err(error) = Self::trial_config_verified(ctx, id, new_config) {
856                        tracing::error!(entry_id = %id, error = %error,
857                            "Loader: config pre-flight failed; old provider kept");
858                        results.push(AppliedAction {
859                            id: id.clone(),
860                            action: "update-config",
861                            status: Err(format!("config pre-flight failed: {error}")),
862                            verified: true,
863                        });
864                        return results;
865                    }
866                    let fid = journal.get(id).and_then(|r| r.fiber_id);
867                    staged.push(Staged::UpdateConfig {
868                        id: id.clone(),
869                        old_config,
870                        new_config: new_config.clone(),
871                        fid,
872                    });
873                }
874                LoaderAction::Begin { id } => {
875                    let Some(entry) = desired.0.iter().find(|e| &e.id == id) else {
876                        results.push(AppliedAction {
877                            id: id.clone(),
878                            action: "begin",
879                            status: Err(format!("entry '{id}' not found in desired tree")),
880                            verified: true,
881                        });
882                        return results;
883                    };
884                    staged.push(Staged::Begin {
885                        id: id.clone(),
886                        entry: entry.clone(),
887                    });
888                }
889                LoaderAction::RebuildFiber { id, plugin } => {
890                    let Some(entry) = desired.0.iter().find(|e| &e.id == id) else {
891                        results.push(AppliedAction {
892                            id: id.clone(),
893                            action: "rebuild-fiber",
894                            status: Err(format!("entry '{id}' not found in desired tree")),
895                            verified: false,
896                        });
897                        return results;
898                    };
899                    staged.push(Staged::RebuildFiber {
900                        id: id.clone(),
901                        entry: entry.clone(),
902                        plugin: plugin.clone(),
903                    });
904                }
905            }
906        }
907
908        // Dependency order inside the staged batch: Begin/RebuildFiber first
909        // (providers must exist before dependents reactivate), then config
910        // updates, then retirements. Ties keep a stable order by entry id so
911        // batches are deterministic regardless of HashMap iteration order.
912        let order_key = |s: &Staged| match s {
913            Staged::Begin { .. } | Staged::RebuildFiber { .. } => 0u8,
914            Staged::UpdateConfig { .. } => 1u8,
915            Staged::Retire { .. } => 2u8,
916        };
917        let tie_key = |s: &Staged| match s {
918            Staged::Retire { id }
919            | Staged::UpdateConfig { id, .. }
920            | Staged::Begin { id, .. }
921            | Staged::RebuildFiber { id, .. } => id.clone(),
922        };
923        staged.sort_by(|a, b| order_key(a).cmp(&order_key(b)).then(tie_key(a).cmp(&tie_key(b))));
924
925        // Phase 2 — APPLY in dependency order, rolling back every
926        // already-applied step when one fails mid-batch. The loader window
927        // spans the whole batch so retire/rebuild disposals never look like
928        // plugin self-kills to the state observers.
929        let _window = ops.as_ref().map(|o| o.enter_loader_window());
930        let mut applied: Vec<Staged> = Vec::new();
931        let mut verified_for: HashMap<String, bool> = HashMap::new();
932
933        for step in staged {
934            let (id, kind): (String, &'static str) = match &step {
935                Staged::Retire { id } => (id.clone(), "retire"),
936                Staged::UpdateConfig { id, .. } => (id.clone(), "update-config"),
937                Staged::Begin { id, .. } => (id.clone(), "begin"),
938                Staged::RebuildFiber { id, .. } => (id.clone(), "rebuild-fiber"),
939            };
940            let (outcome, verified): (Result<(), String>, bool) = match step {
941                Staged::Retire { ref id } => {
942                    // Dispose the live fiber (undo effects) before clearing.
943                    if let Some(record) = journal.get(id) {
944                        if let Some(fid) = record.fiber_id {
945                            if let Some(fiber) = ctx
946                                .get::<crate::RegistryService>()
947                                .and_then(|rs| rs.get_fiber(fid))
948                            {
949                                if let Err(error) = fiber.dispose().await {
950                                    tracing::error!(id = %id, %error, "Loader: fiber stuck in transition during retire");
951                                }
952                            }
953                        }
954                    }
955                    journal.retire(id);
956                    tracing::info!(id = %id, "Loader: retired entry");
957                    (Ok(()), true)
958                }
959                Staged::UpdateConfig { ref id, ref new_config, fid, .. } => {
960                    journal.update_config(id, new_config.clone(), None);
961                    // Drive Fiber::update when a live fiber is known.
962                    if let Some(fiber) = fid.and_then(|f| {
963                        ctx.get::<crate::RegistryService>()
964                            .and_then(|rs| rs.get_fiber(f))
965                    }) {
966                        match Self::drive_fiber_update(ctx, &fiber) {
967                            Ok(()) => (Ok(()), true),
968                            Err(e) => (Err(e), false),
969                        }
970                    } else {
971                        (Ok(()), true)
972                    }
973                }
974                Staged::Begin { ref entry, .. } => match Self::instantiate_entry(ctx, entry) {
975                    Ok(_fid) => (Ok(()), true),
976                    Err(e) => (Err(e.to_string()), false),
977                },
978                Staged::RebuildFiber {
979                    ref id,
980                    ref entry,
981                    ref plugin,
982                } => {
983                    match Self::rebuild_fiber_verified(ctx, id, plugin, entry.clone(), journal).await
984                    {
985                        Ok(v) => (Ok(()), v),
986                        Err(e) => (Err(e), false),
987                    }
988                }
989            };
990            if let Err(err) = outcome {
991                // ROLLBACK: undo everything this batch already applied,
992                // newest-first, then report Failed naming the failing entry.
993                Self::rollback_staged(ctx, &applied, journal).await;
994                results.push(AppliedAction {
995                    id,
996                    action: kind,
997                    status: Err(format!("staged apply failed: {err}; batch rolled back")),
998                    verified,
999                });
1000                return results;
1001            }
1002            verified_for.insert(id, verified);
1003            applied.push(step);
1004        }
1005
1006        // Post-apply detection pass (never fails the batch): a cycle keeps
1007        // its member fibers permanently inactive, so name it at load time.
1008        Self::report_cycles(ctx);
1009        *current = desired.clone();
1010        // Render outcomes in the ORIGINAL reconcile order (stable by entry id
1011        // within each dependency class), not the dependency apply order.
1012        let kind_of = |probe_id: &str| -> &'static str {
1013            match actions.iter().find(|a| match a {
1014                LoaderAction::Begin { id }
1015                | LoaderAction::UpdateConfig { id, .. }
1016                | LoaderAction::Retire { id }
1017                | LoaderAction::RebuildFiber { id, .. } => id == probe_id,
1018            }) {
1019                Some(LoaderAction::Begin { .. }) => "begin",
1020                Some(LoaderAction::UpdateConfig { .. }) => "update-config",
1021                Some(LoaderAction::Retire { .. }) => "retire",
1022                _ => "rebuild-fiber",
1023            }
1024        };
1025        for id in verified_for.keys() {
1026            // Every staged step either succeeded (recorded above) or aborted
1027            // the whole batch earlier, so every id carries an outcome.
1028            let verified = verified_for[id];
1029            results.push(AppliedAction {
1030                id: id.clone(),
1031                action: kind_of(id),
1032                status: Ok(()),
1033                verified,
1034            });
1035        }
1036        results
1037    }
1038
1039    // --- C2 cascade batching -------------------------------------------------
1040    //
1041    // Concurrent PATCH storms against one provider entry used to produce N
1042    // sequential dependency cascades: each `Fiber::update` re-applied the
1043    // plugin and every settle notified dependents, which each re-ran their
1044    // own refresh waves. The in-flight ledger below marks a provider fiber
1045    // "updating" for the duration of its re-apply; dependent fibers consult
1046    // it inside `refresh` and DEFER (resting `Pending` — quiet waiting)
1047    // while any declared dependency is mid-update. When the update finishes,
1048    // ONE trailing refresh per deferred fiber converges the whole cascade.
1049    //
1050    // The ledger keys on fiber id and lives on the loader (process-wide),
1051    // mirroring the journal: absent loader paths degrade to today's
1052    // behavior because nothing ever registers an in-flight window.
1053
1054
1055    /// Mark `fid` as mid-provider-update (reentrant-safe via counting).
1056    fn cascade_begin(fid: u64) {
1057        if let Ok(mut ledger) = CASCADE_INFLIGHT.lock() {
1058            *ledger.entry(fid).or_insert(0) += 1;
1059        }
1060    }
1061
1062    /// End one in-flight window for `fid`; returns `true` when this was the
1063    /// last open window (i.e. the provider just settled).
1064    fn cascade_end(fid: u64) -> bool {
1065        if let Ok(mut ledger) = CASCADE_INFLIGHT.lock() {
1066            match ledger.entry(fid) {
1067                std::collections::hash_map::Entry::Occupied(mut slot) => {
1068                    *slot.get_mut() -= 1;
1069                    if *slot.get() == 0 {
1070                        slot.remove();
1071                        return true;
1072                    }
1073                    return false;
1074                }
1075                std::collections::hash_map::Entry::Vacant(_) => return true,
1076            }
1077        }
1078        true
1079    }
1080
1081    /// Kernel-facing deferral probe (C2): `true` while the provider fiber of
1082    /// ANY of `tids` sits mid-config-update in `ctx`'s realms. The fiber's
1083    /// refresh consults this to defer dependent cascades until the provider
1084    /// settles.
1085    pub(crate) fn cascade_defer_needed(tids: &[TypeId], ctx: &Arc<crate::Context>) -> bool {
1086        let Some(registry) = ctx.get::<crate::RegistryService>() else {
1087            return false;
1088        };
1089        let provider_fids = registry.provider_fibers_for(ctx, tids);
1090        Self::cascade_any_inflight(&provider_fids)
1091    }
1092
1093    /// True when ANY of `fids` currently sits mid-provider-update. Dependents
1094    /// treat "provider updating" as not-ready and defer instead of churning
1095    /// through a cascade wave per concurrent patch.
1096    pub(crate) fn cascade_any_inflight(fids: &[u64]) -> bool {
1097        if fids.is_empty() {
1098            return false;
1099        }
1100        CASCADE_INFLIGHT
1101            .lock()
1102            .map(|ledger| fids.iter().any(|fid| ledger.contains_key(fid)))
1103            .unwrap_or(false)
1104    }
1105
1106    /// Run one live-fiber config update on the hosting runtime:
1107    /// multi-thread runtimes use `block_in_place`; runtimes without a
1108    /// reachable Handle fail the update (the caller rolls back).
1109    ///
1110    /// C2 cascade batching: the whole re-apply runs inside an in-flight
1111    /// ledger window for this fiber, so dependents observing the transient
1112    /// deactivate/reactivate settle ONCE after completion instead of once
1113    /// per intermediate state change.
1114    fn drive_fiber_update(
1115        ctx: &Arc<crate::Context>,
1116        fiber: &std::sync::Arc<crate::Fiber>,
1117    ) -> Result<(), String> {
1118        let fid = fiber.fiber_id().unwrap_or(0);
1119        Self::cascade_begin(fid);
1120        let outcome = match tokio::runtime::Handle::try_current() {
1121            Ok(handle) => {
1122                let ctx_ref = ctx.clone();
1123                let fiber_ref = fiber.clone();
1124                tokio::task::block_in_place(move || {
1125                    handle
1126                        .block_on(async move { fiber_ref.update(&ctx_ref).await })
1127                        .map_err(|e| e.to_string())
1128                })
1129            }
1130            Err(_) => Err("no tokio runtime for live fiber update".to_string()),
1131        };
1132        let settled = Self::cascade_end(fid);
1133        if settled && fid != 0 {
1134            tracing::debug!(fiber_id = fid, "Loader: provider update settled; cascade converges");
1135        }
1136        outcome
1137    }
1138
1139    /// Undo every step of a partially-applied staged batch, newest-first.
1140    ///
1141    /// * Config updates restore the prior journal config (and re-drive the
1142    ///   live fiber so the OLD provider keeps serving).
1143    /// * Began entries are disposed and retired from the journal.
1144    /// * Retired entries are NOT resurrected — the desired tree removed them,
1145    ///   and re-instantiating could re-run side-effectful factories; the
1146    ///   failure report names the failing entry instead. (`current` stays
1147    ///   unchanged either way, so a retry re-diffs cleanly.)
1148    async fn rollback_staged(
1149        ctx: &Arc<crate::Context>,
1150        applied: &[apply_staged::Staged],
1151        journal: &crate::LoaderJournal,
1152    ) {
1153        for step in applied.iter().rev() {
1154            match step {
1155                apply_staged::Staged::UpdateConfig {
1156                    id,
1157                    old_config,
1158                    fid,
1159                    ..
1160                } => {
1161                    journal.update_config(id, old_config.clone(), None);
1162                    if let Some(fiber) = fid.and_then(|f| {
1163                        ctx.get::<crate::RegistryService>()
1164                            .and_then(|rs| rs.get_fiber(f))
1165                    }) {
1166                        let _ = Self::drive_fiber_update(ctx, &fiber);
1167                    }
1168                }
1169                apply_staged::Staged::RebuildFiber { id, .. } => {
1170                    // The rebuild already swapped registrations under this
1171                    // id: dispose whatever fiber the swap left behind so the
1172                    // failed batch leaves no half-applied provider serving.
1173                    if let Some(record) = journal.get(id) {
1174                        if let Some(fid) = record.fiber_id {
1175                            if let Some(fiber) = ctx
1176                                .get::<crate::RegistryService>()
1177                                .and_then(|rs| rs.get_fiber(fid))
1178                            {
1179                                let _ = fiber.dispose().await;
1180                            }
1181                        }
1182                    }
1183                }
1184                apply_staged::Staged::Begin { entry, .. } => {
1185                    if let Some(record) = journal.get(&entry.id) {
1186                        if let Some(fid) = record.fiber_id {
1187                            if let Some(fiber) = ctx
1188                                .get::<crate::RegistryService>()
1189                                .and_then(|rs| rs.get_fiber(fid))
1190                            {
1191                                let _ = fiber.dispose().await;
1192                            }
1193                        }
1194                    }
1195                    journal.retire(&entry.id);
1196                }
1197                apply_staged::Staged::Retire { .. } => {}
1198            }
1199        }
1200    }
1201}
1202
1203/// Module-scoped staging types shared by [`Loader::apply`] and
1204/// [`Loader::rollback_staged`] (the enum lives here rather than inside
1205/// `apply` so the rollback can name its variants).
1206mod apply_staged {
1207    use crate::loader::Entry;
1208
1209    pub(super) enum Staged {
1210        Retire {
1211            id: String,
1212        },
1213        UpdateConfig {
1214            id: String,
1215            old_config: serde_json::Value,
1216            new_config: serde_json::Value,
1217            fid: Option<crate::FiberId>,
1218        },
1219        Begin {
1220            id: String,
1221            entry: Entry,
1222        },
1223        RebuildFiber {
1224            id: String,
1225            entry: Entry,
1226            plugin: String,
1227        },
1228    }
1229}
1230
1231impl Loader {
1232    /// Run dependency-cycle detection over every entry this loader has
1233    /// instantiated.
1234    ///
1235    /// The post-apply inject graph is reconstructed by
1236    /// [`crate::cycles::build_dependency_graph`] from the lazily-provided
1237    /// [`crate::cycles::CycleLedger`] plus registry lookups; returns one path
1238    /// per detected cycle (closed, canonical rotation) and an empty vec for a
1239    /// healthy graph or library deployments without ledger/registry state.
1240    pub fn detect_cycles(ctx: &Arc<crate::Context>) -> Vec<Vec<crate::FiberId>> {
1241        match crate::cycles::build_dependency_graph(ctx) {
1242            Some(graph) => crate::cycles::find_dependency_cycles(&graph),
1243            None => Vec::new(),
1244        }
1245    }
1246
1247    /// [`Self::detect_cycles`] with every fiber id resolved to its owning
1248    /// entry id via the [`LoaderJournal`] (untracked fibers fall back to
1249    /// their stringified id) — the shape admin surfaces report.
1250    pub fn detect_cycle_entry_ids(ctx: &Arc<crate::Context>) -> Vec<Vec<String>> {
1251        let cycles = Self::detect_cycles(ctx);
1252        let journal = ctx.get::<crate::LoaderJournal>();
1253        Self::cycle_entry_ids(journal.as_deref(), &cycles)
1254    }
1255
1256    /// Map fiber ids onto their owning entry ids via the [`LoaderJournal`]
1257    /// (untracked fibers fall back to their stringified id).
1258    fn cycle_entry_ids(
1259        journal: Option<&crate::LoaderJournal>,
1260        cycles: &[Vec<crate::FiberId>],
1261    ) -> Vec<Vec<String>> {
1262        cycles
1263            .iter()
1264            .map(|cycle| {
1265                cycle
1266                    .iter()
1267                    .map(|fid| {
1268                        journal
1269                            .and_then(|j| {
1270                                j.records.read().iter().find_map(|(id, rec)| {
1271                                    (rec.fiber_id == Some(*fid)).then(|| id.clone())
1272                                })
1273                            })
1274                            .unwrap_or_else(|| fid.to_string())
1275                    })
1276                    .collect()
1277            })
1278            .collect()
1279    }
1280
1281    /// Post-apply detection pass: report any inject-dependency cycle without
1282    /// failing the batch. A cycle keeps its members permanently inactive (each
1283    /// waits on the other's provider), which is fully predictable from the
1284    /// declarations and therefore worth naming at load time.
1285    fn report_cycles(ctx: &Arc<crate::Context>) {
1286        let journal = ctx.get::<crate::LoaderJournal>();
1287        let cycles = Self::detect_cycles(ctx);
1288        if cycles.is_empty() {
1289            return;
1290        }
1291        let entry_ids = Self::cycle_entry_ids(journal.as_deref(), &cycles);
1292        tracing::warn!(
1293            entry_ids = ?entry_ids,
1294            fibers = ?cycles,
1295            "dependency cycle detected among loaded entries; affected fibers will remain inactive until the cycle is broken"
1296        );
1297    }
1298}
1299
1300impl Loader {
1301    pub fn new() -> Self {
1302        Self
1303    }
1304
1305    /// Canonical persistence path (`config/entries.json`).
1306    pub fn persist_path() -> &'static str {
1307        ENTRIES_PATH
1308    }
1309
1310    /// Alternative toon persistence path (`config/cordis-entries.toon`).
1311    pub fn toon_path() -> &'static str {
1312        CORDIS_ENTRIES_TOON_PATH
1313    }
1314
1315    /// Load an [`EntryTree`] from a TOML file (`config/cordis-entries.toml`).
1316    ///
1317    /// Expected format:
1318    /// ```toml
1319    /// [[entry]]
1320    /// id = "calculator"
1321    /// plugin = "CalculatorService"
1322    /// disabled = false
1323    ///
1324    /// [entry.config]
1325    /// ```
1326    pub fn load_from_file(path: &std::path::Path) -> Result<EntryTree, CordisError> {
1327        let content = std::fs::read_to_string(path).map_err(|e| {
1328            CordisError::Configuration(format!("failed to read {}: {}", path.display(), e))
1329        })?;
1330        let parsed: TomlEntries = toml::from_str(&content).map_err(|e| {
1331            CordisError::Configuration(format!("failed to parse {}: {}", path.display(), e))
1332        })?;
1333        Ok(EntryTree(parsed.entry))
1334    }
1335
1336    /// Incremental diff `current → desired` producing ordered [`LoaderAction`]s.
1337    ///
1338    /// Rules (per-field dispatch):
1339    /// - missing `id` in `current` → `Begin` (if not disabled)
1340    /// - `id` in `current` but not `desired` → `Retire`
1341    /// - `plugin` changed → `RebuildFiber`
1342    /// - `config` changed → `UpdateConfig`
1343    /// - `disabled` toggled → `Retire` / `Begin`
1344    /// - `isolate` or `intercept` changed → `RebuildFiber`
1345    pub fn reconcile(&self, current: &EntryTree, desired: &EntryTree) -> Vec<LoaderAction> {
1346        let mut curr_map: HashMap<&str, &Entry> = HashMap::new();
1347        for e in &current.0 {
1348            curr_map.insert(e.id.as_str(), e);
1349        }
1350        let mut desired_map: HashMap<&str, &Entry> = HashMap::new();
1351        for e in &desired.0 {
1352            desired_map.insert(e.id.as_str(), e);
1353        }
1354
1355        let mut actions: Vec<LoaderAction> = Vec::new();
1356
1357        // Retire entries removed from desired (Confluence: withdrawal).
1358        for id in curr_map.keys() {
1359            if !desired_map.contains_key(*id) {
1360                actions.push(LoaderAction::Retire {
1361                    id: (*id).to_string(),
1362                });
1363            }
1364        }
1365
1366        for (id, desired_entry) in &desired_map {
1367            match curr_map.get(*id) {
1368                None => {
1369                    // New id: Begin unless it is already disabled.
1370                    if !desired_entry.disabled {
1371                        actions.push(LoaderAction::Begin {
1372                            id: (*id).to_string(),
1373                        });
1374                    }
1375                }
1376                Some(curr_entry) => {
1377                    // plugin / id change → rebuild (id is key, so plugin diff is the signal)
1378                    if curr_entry.plugin != desired_entry.plugin {
1379                        actions.push(LoaderAction::RebuildFiber {
1380                            id: (*id).to_string(),
1381                            plugin: desired_entry.plugin.clone(),
1382                        });
1383                        continue;
1384                    }
1385                    // isolate / intercept spatial change → rebuild
1386                    if curr_entry.isolate != desired_entry.isolate
1387                        || curr_entry.intercept != desired_entry.intercept
1388                    {
1389                        actions.push(LoaderAction::RebuildFiber {
1390                            id: (*id).to_string(),
1391                            plugin: desired_entry.plugin.clone(),
1392                        });
1393                        continue;
1394                    }
1395                    // config change → update (fiber.update(new_config))
1396                    if curr_entry.config != desired_entry.config {
1397                        actions.push(LoaderAction::UpdateConfig {
1398                            id: (*id).to_string(),
1399                            new_config: desired_entry.config.clone(),
1400                        });
1401                        continue;
1402                    }
1403                    // disabled toggle → retire / begin
1404                    if curr_entry.disabled != desired_entry.disabled {
1405                        if desired_entry.disabled {
1406                            actions.push(LoaderAction::Retire {
1407                                id: (*id).to_string(),
1408                            });
1409                        } else {
1410                            actions.push(LoaderAction::Begin {
1411                                id: (*id).to_string(),
1412                            });
1413                        }
1414                        continue;
1415                    }
1416                }
1417            }
1418        }
1419
1420        actions
1421    }
1422
1423    /// Execute a reconciliation action against the context.
1424    ///
1425    /// `Begin` / `RebuildFiber` require the plugin factory from the
1426    /// [`crate::PluginRegistry`]; when it is not provided (or no factory is
1427    /// registered under the entry's `plugin` name) these arms fall back to
1428    /// log-only. Startup instantiation of new entries goes through
1429    /// [`Loader::instantiate`] instead, which reports per-entry results.
1430    ///
1431    /// The [`crate::LoaderJournal`] (when provided as a `Service`) makes the
1432    /// `UpdateConfig` and `Retire` arms real: `UpdateConfig` stores the new
1433    /// config, bumps `generation`, and calls `Fiber::update` when the journal
1434    /// knows the live fiber id (leaning on [`crate::RegistryService::get_fiber`]
1435    /// to resolve it); `Retire` clears the record and bumps `generation`.
1436    /// When the journal is absent both arms stay log-only.
1437    pub fn execute_action(action: &LoaderAction, ctx: &std::sync::Arc<crate::Context>) {
1438        let journal = ctx.get::<LoaderJournal>();
1439        let registry = ctx.get::<crate::PluginRegistry>();
1440        match action {
1441            LoaderAction::RebuildFiber { id, plugin } => {
1442                let Some(registry) = registry else {
1443                    tracing::warn!(id = %id, plugin = %plugin,
1444                        "PluginRegistry not provided; loader actions are log-only");
1445                    return;
1446                };
1447                match registry
1448                    .get(plugin)
1449                    .ok_or_else(|| {
1450                        crate::CordisError::Configuration(format!(
1451                            "no factory registered for plugin '{plugin}'"
1452                        ))
1453                    })
1454                    .and_then(|factory| factory(ctx, &serde_json::Value::Null))
1455                {
1456                    Ok(fid) => {
1457                        if let Some(journal) = &journal {
1458                            journal.upsert(id, plugin, serde_json::Value::Null, Some(fid));
1459                        }
1460                        tracing::info!(id = %id, plugin = %plugin, fiber_id = %fid,
1461                            "Loader: rebuilt fiber for entry");
1462                    }
1463                    Err(e) => {
1464                        tracing::warn!(id = %id, plugin = %plugin, error = %e, "Loader: rebuild failed");
1465                    }
1466                }
1467            }
1468            LoaderAction::UpdateConfig { id, new_config } => {
1469                let Some(journal) = journal else {
1470                    tracing::info!(id = %id, "Loader: updating fiber config for entry");
1471                    return;
1472                };
1473                // Resolve the live fiber from the journal's recorded id so a
1474                // config-only change can drive `Fiber::update` (recompute epoch
1475                // + dependency satisfaction) rather than a full rebuild.
1476                let recorded = journal.get(id).and_then(|r| r.fiber_id);
1477                let fiber = if let Some(fid) = recorded {
1478                    ctx.get::<crate::RegistryService>()
1479                        .and_then(|rs| rs.get_fiber(fid))
1480                } else {
1481                    None
1482                };
1483                if let Some(fiber) = fiber {
1484                    // `Fiber::update` is async; run it inline only when we are
1485                    // inside a multi-thread tokio runtime (as production
1486                    // hot-reload is), matching the `block_in_place` pattern used
1487                    // by the plugin factories. Hosting a current-thread runtime
1488                    // or no runtime at all leaves the update journal-only so we
1489                    // never panic on `block_in_place`/`block_on`.
1490                    match tokio::runtime::Handle::try_current() {
1491                        Ok(handle)
1492                            if handle.runtime_flavor()
1493                                == tokio::runtime::RuntimeFlavor::CurrentThread =>
1494                        {
1495                            tracing::info!(id = %id,
1496                                "Loader: current-thread runtime; fiber config update is journal-only");
1497                        }
1498                        Ok(handle) => {
1499                            tracing::info!(id = %id, "Loader: applying fiber config update (live fiber)");
1500                            let ctx_ref = ctx.clone();
1501                            let fiber_ref = fiber.clone();
1502                            // Legacy log-only arm: a kernel-refused update is
1503                            // surfaced by the fiber's own Failed state; the
1504                            // journal already carries the new config either way.
1505                            let _ = tokio::task::block_in_place(move || {
1506                                handle.block_on(fiber_ref.update(&ctx_ref))
1507                            });
1508                        }
1509                        Err(_) => {
1510                            tracing::info!(id = %id,
1511                                "Loader: no tokio runtime in scope; fiber config update is journal-only");
1512                        }
1513                    }
1514                } else {
1515                    tracing::info!(id = %id, "Loader: no live fiber for entry; journal-only config update");
1516                }
1517                journal.update_config(id, new_config.clone(), recorded);
1518                tracing::info!(id = %id, config = %new_config, "Loader: updated fiber config for entry");
1519            }
1520            LoaderAction::Retire { id } => {
1521                if let Some(journal) = &journal {
1522                    if let Some(removed) = journal.retire(id) {
1523                        tracing::info!(id = %id, plugin = %removed.plugin,
1524                            "Loader: retired entry (journal record cleared)");
1525                    } else {
1526                        tracing::info!(id = %id, "Loader: retiring entry (no journal record)");
1527                    }
1528                } else {
1529                    tracing::info!(id = %id, "Loader: retiring entry");
1530                }
1531            }
1532            LoaderAction::Begin { id } => {
1533                // `Entry.plugin` is not carried by this action; startup
1534                // resolves plugin names via `Loader::instantiate` on the
1535                // desired tree instead.
1536                tracing::info!(id = %id, "Loader: beginning entry");
1537            }
1538        }
1539    }
1540
1541    /// Diff the caller-supplied composed `desired_composed` tree (includes
1542    /// resolved, groups flattened, configs interpolated — see `compose_all`)
1543    /// against the `CurrentEntries`-style current tree and apply for real.
1544    ///
1545    /// This is the runtime hot-reload primitive shared by the file watcher and
1546    /// the admin reload endpoint. Callers own parsing + composition; returns
1547    /// per-action outcomes for the diff that was applied.
1548    pub async fn reload_current(
1549        ctx: &Arc<crate::Context>,
1550        path: &std::path::Path,
1551        current: &mut EntryTree,
1552        desired_composed: &EntryTree,
1553        journal: &crate::LoaderJournal,
1554    ) -> Option<Vec<AppliedAction>> {
1555        // `desired_composed` is the caller-composed tree (includes resolved,
1556        // groups flattened, configs interpolated); `path` is kept for logs.
1557        tracing::debug!(
1558            path = %path.display(),
1559            entries = desired_composed.0.len(),
1560            "Cordis hot-reload: applying composed desired state"
1561        );
1562        let mut desired = desired_composed.clone();
1563        if let Some(handle) = ctx.get::<crate::loader::EntryConfigFillerHandle>() {
1564            handle.0.fill_empty_entry_configs(&mut desired);
1565        }
1566        Some(Self::apply(ctx, current, &desired, journal).await)
1567    }
1568
1569    /// Rebuild an entry's fiber with swap-with-verification.
1570    ///
1571    /// When the old registration fiber is known, the replacement plugin is
1572    /// applied OUT-OF-BAND first against a scratch child context: the factory
1573    /// runs and builds its services there, so a failure leaves the live
1574    /// provider completely untouched. Only after the candidate applies `Ok`
1575    /// does the swap proceed: the new instances are bridged in as intercept
1576    /// overrides (intercept lookups precede store lookups, so `get` keeps
1577    /// resolving), the old fiber retires, and the bridged values are promoted
1578    /// into the store under a fresh registration fiber.
1579    ///
1580    /// Fallback to the classic dispose-then-rebuild path — reported as
1581    /// `Ok(false)` ("unverified") — when there is no tracked old fiber or the
1582    /// entry targets an isolate realm (isolated lookups do not consult
1583    /// intercepts). A failed candidate returns `Err` and keeps the old
1584    /// provider serving.
1585    ///
1586    /// Note: the trial executes the factory once, so factories with external
1587    /// side effects (e.g. migrations) run twice across trial + promotion;
1588    /// such plugins should be swapped through the unverified path instead.
1589    async fn rebuild_fiber_verified(
1590        ctx: &Arc<crate::Context>,
1591        id: &str,
1592        plugin_name: &str,
1593        entry: Entry,
1594        journal: &crate::LoaderJournal,
1595    ) -> Result<bool, String> {
1596        let registry = ctx.get::<crate::RegistryService>();
1597        let old_fid = journal.get(id).and_then(|r| r.fiber_id);
1598        let old_fiber = old_fid
1599            .as_ref()
1600            .and_then(|fid| registry.as_ref()?.get_fiber(*fid));
1601        let Some((registry, old_fiber, old_fid)) = registry
1602            .zip(old_fiber)
1603            .zip(old_fid)
1604            .map(|((r, f), i)| (r, f, i))
1605        else {
1606            tracing::warn!(entry_id = %id, plugin = %plugin_name, swap_mode = "unverified",
1607                "Loader: no tracked fiber for rebuild; dispose-then-rebuild");
1608            return Self::retire_then_instantiate(ctx, entry)
1609                .await
1610                .map(|_| false);
1611        };
1612        if entry.isolate.is_some() {
1613            tracing::warn!(entry_id = %id, plugin = %plugin_name, swap_mode = "unverified",
1614                "Loader: isolated entry rebuild; dispose-then-rebuild");
1615            return Self::retire_then_instantiate(ctx, entry)
1616                .await
1617                .map(|_| false);
1618        }
1619
1620        // Out-of-band trial: build the candidate on a scratch child context.
1621        // The parent chain keeps every dependency resolvable while the
1622        // duplicate-provider discipline of the empty scratch store prevents
1623        // collisions; nothing lands on the live provider.
1624        let scratch = ctx.extend();
1625        let Some(plugin_registry) = scratch.get::<crate::PluginRegistry>() else {
1626            return Err("PluginRegistry missing".to_string());
1627        };
1628        let Some(factory) = plugin_registry.get(&entry.plugin) else {
1629            return Err(format!("no factory registered for plugin '{plugin_name}'"));
1630        };
1631        let trial_fiber = std::sync::Arc::new(crate::Fiber::new());
1632        trial_fiber.set_state(crate::FiberState::Loading);
1633        let trial = scratch.with_provider_fiber(&trial_fiber, || factory(&scratch, &entry.config));
1634        // A trial factory calling Context::plugin re-points ReflectService at
1635        // the scratch context; restore the authoritative root binding.
1636        if let Some(reflect) = ctx.get::<crate::ReflectService>() {
1637            reflect.set_context(ctx);
1638        }
1639        if let Err(e) = trial {
1640            tracing::warn!(entry_id = %id, plugin = %plugin_name, error = %e,
1641                "Loader: verified swap trial failed; old provider kept");
1642            return Err(e.to_string());
1643        }
1644
1645        // Every TypeId freshly built in scratch AND currently served by the
1646        // root context is replaced by this rebuild (the plugin's Provides plus
1647        // nested provides owned by the same registration fiber).
1648        let built: Vec<TypeId> = scratch.provided_type_ids();
1649        let replaced: Vec<TypeId> = built
1650            .iter()
1651            .copied()
1652            .filter(|tid| ctx.get_untyped(*tid).is_some())
1653            .collect();
1654        if replaced.is_empty() {
1655            tracing::warn!(entry_id = %id, plugin = %plugin_name, swap_mode = "unverified",
1656                "Loader: trial produced no comparable services; dispose-then-rebuild");
1657            return Self::retire_then_instantiate(ctx, entry)
1658                .await
1659                .map(|_| false);
1660        }
1661
1662        let new_fid = SwapPromotion {
1663            ctx,
1664            registry: registry.as_ref(),
1665            scratch: &scratch,
1666            epoch: &entry.id,
1667            intercept_overlay: Some(&entry.intercept),
1668            built: &built,
1669            replaced: &replaced,
1670            old_fiber,
1671            old_fid,
1672        }
1673        .run()
1674        .await;
1675        journal.upsert(id, &entry.plugin, entry.config.clone(), Some(new_fid));
1676        tracing::info!(entry_id = %id, plugin = %plugin_name, old_fiber_id = %old_fid,
1677            new_fiber_id = %new_fid, swap_mode = "verified",
1678            "Loader: hot-swapped provider with verification");
1679        Ok(true)
1680    }
1681
1682    /// Pre-flight trial for [`LoaderAction::UpdateConfig`]: build the plugin
1683    /// with the NEW config on a scratch child context exactly like the
1684    /// out-of-band trial in [`Self::rebuild_fiber_verified`], then DISCARD
1685    /// the candidate. Nothing is bridged or promoted — this only answers
1686    /// "would the new configuration apply cleanly?" so a broken config can
1687    /// never take down the live fiber's re-apply. Returns the factory error
1688    /// verbatim on failure.
1689    ///
1690    /// Absent registry/factory means there is nothing to pre-flight (the
1691    /// classic journal-only update path applies); that is not an error.
1692    fn trial_config_verified(
1693        ctx: &Arc<crate::Context>,
1694        id: &str,
1695        new_config: &serde_json::Value,
1696    ) -> Result<(), String> {
1697        let scratch = ctx.extend();
1698        let Some(plugin_registry) = scratch.get::<crate::PluginRegistry>() else {
1699            return Ok(());
1700        };
1701        // The entry's factory label comes from the journaled record; unknown
1702        // ids have no factory to trial and fall through to journal-only.
1703        let Some(record) = ctx.get::<crate::LoaderJournal>().and_then(|j| j.get(id)) else {
1704            return Ok(());
1705        };
1706        let Some(factory) = plugin_registry.get(&record.plugin) else {
1707            return Ok(());
1708        };
1709        let trial_fiber = std::sync::Arc::new(crate::Fiber::new());
1710        trial_fiber.set_state(crate::FiberState::Loading);
1711        let trial =
1712            scratch.with_provider_fiber(&trial_fiber, || factory(&scratch, &new_config.clone()));
1713        // A trial factory calling Context::plugin re-points ReflectService at
1714        // the scratch context; restore the authoritative root binding.
1715        if let Some(reflect) = ctx.get::<crate::ReflectService>() {
1716            reflect.set_context(ctx);
1717        }
1718        trial.map(|_| ()).map_err(|e| {
1719            // Preserve machine-readable issues before flattening to the
1720            // action-row string; a non-validation error clears any stale
1721            // slot for this entry.
1722            crate::error::stash_trial_validation(id, &e);
1723            e.to_string()
1724        })
1725    }
1726
1727    /// Per-entry stash of the most recent structured validation failures
1728    /// from [`Self::trial_config_verified`] pre-flights.
1729    ///
1730    /// `AppliedAction` rows carry plain strings, so the admin PATCH surface
1731    /// could not answer 4xx with machine-readable issues. Trials record here
1732    /// keyed by entry id ([`crate::error::stash_trial_validation`]); the
1733    /// HTTP layer consumes the slot after a failed apply. Slots mirror the
1734    /// LATEST trial outcome — recording a non-validation error clears the
1735    /// entry, and consumption removes it.
1736    pub fn take_trial_validation(entry_id: &str) -> Option<crate::error::ValidationError> {
1737        crate::error::take_trial_validation(entry_id)
1738    }
1739
1740    /// Broker a rolling provider replacement with zero absence window
1741    /// (paper §6 semantics).
1742    ///
1743    /// Resolves the live registration from the [`crate::LoaderJournal`] by
1744    /// plugin label (first journaled entry whose `plugin` matches — the same
1745    /// label also selects the replacement factory from the
1746    /// [`crate::PluginRegistry`], mirroring how admins name a running
1747    /// provider), trials that factory with the NEW config OUT-OF-BAND on a
1748    /// scratch child context exactly like [`Self::rebuild_fiber_verified`],
1749    /// and only then swaps: the new
1750    /// instances are bridged in as intercept overrides (intercept lookups
1751    /// precede store lookups, so `get` keeps resolving), the old fiber
1752    /// retires, and the bridged values are promoted into the store under a
1753    /// fresh registration fiber before the bridge drops. Consumers observe no
1754    /// gap: every lookup stays satisfied at every instant because the key
1755    /// never becomes unprovided.
1756    ///
1757    /// The old fiber is disposed DIRECTLY through its registration fiber
1758    /// instead of going through [`Context::remove`] — this deliberately
1759    /// bypasses the public guarded-withdrawal check. The guard exists to
1760    /// refuse removals that would leave active consumers UNRESOLVED; here
1761    /// resolution stays continuous by construction (the bridge is installed
1762    /// before disposal), which is precisely why the broker may bypass it.
1763    /// Genuine withdrawals (the admin retire endpoint) must keep using the
1764    /// guarded path.
1765    ///
1766    /// Failure policy: a failing trial returns `Err` and leaves the old
1767    /// provider serving untouched; the journal advances only on success
1768    /// (generation bump + new fiber id).
1769    ///
1770    /// Root-realm only for now: if the trial produces services carrying an
1771    /// isolate label, the call fails with [`CordisError::Configuration`]
1772    /// naming the limitation — isolated lookups skip intercept overrides, so
1773    /// the bridge mechanism cannot cover them.
1774    pub async fn replace_provider(
1775        &self,
1776        ctx: &Arc<crate::Context>,
1777        plugin_name: &str,
1778        config: serde_json::Value,
1779        journal: &crate::LoaderJournal,
1780    ) -> Result<crate::FiberId, CordisError> {
1781        // Resolve the old registration by plugin label from the journal.
1782        let (id, record) = journal
1783            .records
1784            .read()
1785            .iter()
1786            .find(|(_, rec)| rec.plugin == plugin_name)
1787            .map(|(id, rec)| (id.clone(), rec.clone()))
1788            .ok_or_else(|| {
1789                CordisError::Configuration(format!(
1790                    "replace_provider: no journaled entry for plugin '{plugin_name}'"
1791                ))
1792            })?;
1793        let old_fid = record.fiber_id.ok_or_else(|| {
1794            CordisError::Configuration(format!(
1795                "replace_provider: entry '{id}' has no tracked fiber"
1796            ))
1797        })?;
1798        let registry = ctx
1799            .get::<crate::RegistryService>()
1800            .ok_or_else(|| CordisError::Configuration("RegistryService missing".into()))?;
1801        let old_fiber = registry.get_fiber(old_fid).ok_or_else(|| {
1802            CordisError::Configuration(format!(
1803                "replace_provider: fiber {old_fid} for entry '{id}' not tracked"
1804            ))
1805        })?;
1806
1807        // Out-of-band trial: identical discipline to rebuild_fiber_verified —
1808        // the candidate is built on an empty scratch child of the live
1809        // context, so a failing factory cannot touch the serving provider.
1810        let scratch = ctx.extend();
1811        let Some(plugin_registry) = scratch.get::<crate::PluginRegistry>() else {
1812            return Err(CordisError::Configuration("PluginRegistry missing".into()));
1813        };
1814        let Some(factory) = plugin_registry.get(plugin_name) else {
1815            return Err(CordisError::Configuration(format!(
1816                "no factory registered for plugin '{plugin_name}'"
1817            )));
1818        };
1819        let trial_fiber = std::sync::Arc::new(crate::Fiber::new());
1820        trial_fiber.set_state(crate::FiberState::Loading);
1821        let trial = scratch.with_provider_fiber(&trial_fiber, || factory(&scratch, &config));
1822        // A trial factory calling Context::plugin re-points ReflectService at
1823        // the scratch context; restore the authoritative root binding.
1824        if let Some(reflect) = ctx.get::<crate::ReflectService>() {
1825            reflect.set_context(ctx);
1826        }
1827        let built: Vec<TypeId> = match trial {
1828            Ok(_) => scratch.provided_type_ids(),
1829            Err(e) => {
1830                tracing::warn!(entry_id = %id, plugin = %plugin_name, error = %e,
1831                    "Loader: replace_provider trial failed; old provider kept");
1832                return Err(e);
1833            }
1834        };
1835
1836        // Root realm only: isolated lookups bypass intercepts, so the bridge
1837        // cannot serve them. Nothing has been mutated yet — fail clean.
1838        if let Some(isolated) = built
1839            .iter()
1840            .copied()
1841            .find(|tid| ctx.isolate_label(*tid).is_some())
1842        {
1843            return Err(CordisError::Configuration(format!(
1844                "replace_provider: isolated providers not supported yet \
1845                 (trial built an isolated service, e.g. {isolated:?})"
1846            )));
1847        }
1848        let replaced: Vec<TypeId> = built
1849            .iter()
1850            .copied()
1851            .filter(|tid| ctx.get_untyped(*tid).is_some())
1852            .collect();
1853        if replaced.is_empty() {
1854            // Unlike rebuild_fiber_verified there is NO dispose-then-rebuild
1855            // fallback here: blind disposal is exactly the absence window the
1856            // broker exists to eliminate.
1857            return Err(CordisError::Configuration(format!(
1858                "replace_provider: trial produced no comparable services for '{plugin_name}'"
1859            )));
1860        }
1861
1862        let new_fid = SwapPromotion {
1863            ctx,
1864            registry: registry.as_ref(),
1865            scratch: &scratch,
1866            epoch: &id,
1867            intercept_overlay: None,
1868            built: &built,
1869            replaced: &replaced,
1870            old_fiber,
1871            old_fid,
1872        }
1873        .run()
1874        .await;
1875
1876        journal.upsert(&id, plugin_name, config, Some(new_fid));
1877        tracing::info!(entry_id = %id, plugin = %plugin_name, old_fiber_id = %old_fid,
1878            new_fiber_id = %new_fid, swap_mode = "verified",
1879            "Loader: replace_provider swapped provider with zero absence window");
1880        Ok(new_fid)
1881    }
1882
1883    /// Classic rebuild: dispose the old fiber, then instantiate the entry
1884    /// through the normal factory path.
1885    async fn retire_then_instantiate(
1886        ctx: &Arc<crate::Context>,
1887        entry: Entry,
1888    ) -> Result<(), String> {
1889        match Self::instantiate_entry(ctx, &entry) {
1890            Ok(_fid) => Ok(()),
1891            Err(e) => Err(e.to_string()),
1892        }
1893    }
1894
1895    /// Instantiate one entry by plugin name through the [`crate::PluginRegistry`].
1896    ///
1897    /// Looks up the factory registered under `plugin_name`, invokes it with
1898    /// `(ctx, config)` so the plugin lands via `Context::plugin` (single-source
1899    /// discipline applies), and returns the resulting fiber id. When the
1900    /// [`crate::LoaderJournal`] is provided, the successful instantiation
1901    /// records `{plugin, config, fiber_id: Some(fid), generation+1}` so later
1902    /// `UpdateConfig` / `Retire` actions can resolve the live fiber. Missing
1903    /// registry or missing factory are `CordisError::Configuration`.
1904    pub fn instantiate(
1905        ctx: &Arc<crate::Context>,
1906        plugin_name: &str,
1907        config: &serde_json::Value,
1908        entry_id: &str,
1909    ) -> Result<crate::FiberId, crate::CordisError> {
1910        Self::instantiate_entry(
1911            ctx,
1912            &Entry {
1913                id: entry_id.to_string(),
1914                plugin: plugin_name.to_string(),
1915                config: config.clone(),
1916                disabled: false,
1917                isolate: None,
1918                intercept: HashMap::new(),
1919                            position: None,
1920            },
1921        )
1922    }
1923
1924    /// Instantiate one [`Entry`], applying `isolate` / `intercept` onto `ctx`.
1925    ///
1926    /// `intercept` is bound first so the factory can read [`EntryIntercept`].
1927    /// After the factory provides, newly inserted TypeIds are labeled with
1928    /// `isolate` so `get_isolated` matches the entry's realm.
1929    pub fn instantiate_entry(
1930        ctx: &Arc<crate::Context>,
1931        entry: &Entry,
1932    ) -> Result<crate::FiberId, crate::CordisError> {
1933        if !entry.intercept.is_empty() {
1934            ctx.bind_intercept(EntryIntercept(entry.intercept.clone()));
1935        }
1936        let before: HashSet<TypeId> = ctx.provided_type_ids().into_iter().collect();
1937        let Some(registry) = ctx.get::<crate::PluginRegistry>() else {
1938            return Err(crate::CordisError::Configuration(
1939                "PluginRegistry missing".into(),
1940            ));
1941        };
1942        let Some(factory) = registry.get(&entry.plugin) else {
1943            return Err(crate::CordisError::Configuration(format!(
1944                "no factory registered for plugin '{}'",
1945                entry.plugin
1946            )));
1947        };
1948        // Dedicated registration fiber: every provide the factory performs is
1949        // owned by this fiber, so `apply`'s Retire can dispose exactly this
1950        // entry's effects without touching unrelated services.
1951        let fiber = std::sync::Arc::new(crate::Fiber::new());
1952        fiber.set_state(crate::FiberState::Loading);
1953        // RegistryService is optional: when absent (library deployments),
1954        // effects still land on the dedicated fiber but disposal-by-retire
1955        // cannot resolve it later.
1956        let tracked = ctx
1957            .get::<crate::RegistryService>()
1958            .map(|rs| rs.track_fiber(fiber.clone()));
1959        // When RegistryService is absent, mint a placeholder id so the journal
1960        // record still exists (retire will be journal-only in that mode).
1961        #[allow(unused_variables)]
1962        let fid = tracked.unwrap_or_else(|| {
1963            crate::context::NEXT_FIBER_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst) as u64
1964        });
1965        // Mark the registration fiber active before the factory runs so nested
1966        // provides (e.g. Store → TenantDb) are immediately resolvable; flip to
1967        // Failed if the factory errors afterwards.
1968        fiber.set_state(crate::FiberState::Active {
1969            epoch: entry.id.clone(),
1970        });
1971        let outcome = ctx.with_provider_fiber(&fiber, || factory(ctx, &entry.config));
1972        // The TRACKED fiber id identifies this registration for later
1973        // retirement; the factory's own return value (often from an inner
1974        // `ctx.plugin`) is irrelevant to the loader's lifecycle bookkeeping.
1975        let fid = match outcome {
1976            Ok(_factory_fid) => tracked.unwrap_or_else(|| {
1977                crate::context::NEXT_FIBER_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
1978                    as u64
1979            }),
1980            Err(e) => {
1981                fiber.set_state(crate::FiberState::Failed {
1982                    error: Some(e.to_string()),
1983                });
1984                return Err(e);
1985            }
1986        };
1987        // Lazy ledger provision: every provide the factory performed is
1988        // recorded as `(type, realm) -> fid` so post-apply cycle detection can
1989        // reconstruct the inject graph. Library deployments that never touch
1990        // this path simply never see a ledger.
1991        if ctx.get::<crate::cycles::CycleLedger>().is_none() {
1992            ctx.provide(crate::cycles::CycleLedger::new());
1993        }
1994        let ledger = ctx
1995            .get::<crate::cycles::CycleLedger>()
1996            .expect("ledger just provided");
1997        for tid in ctx.provided_type_ids() {
1998            if !before.contains(&tid) {
1999                ledger.record_provider(tid, ctx.isolate_label(tid).as_deref(), fid);
2000            }
2001        }
2002        ledger.note_entry(fid, &entry.id);
2003        if let Some(label) = entry.isolate.as_deref() {
2004            for tid in ctx.provided_type_ids() {
2005                if !before.contains(&tid) {
2006                    ctx.bind_isolate(tid, label);
2007                }
2008            }
2009        }
2010        if let Some(journal) = ctx.get::<LoaderJournal>() {
2011            journal.upsert(&entry.id, &entry.plugin, entry.config.clone(), Some(fid));
2012        }
2013        // Self-kill detection: observe the registration fiber so an
2014        // out-of-band disposal (the plugin disposing ITSELF, outside any
2015        // loader reconcile window) persists `disabled = true` for this entry.
2016        if let Some(ops) = ctx.get::<LoaderOps>() {
2017            ops.record_apply(&entry.id);
2018            Self::watch_entry_fiber(&ops, &fiber, &entry.id);
2019        }
2020        tracing::info!(entry_id=%entry.id, plugin=%entry.plugin, fiber_id=%fid, "Loader: instantiated plugin");
2021        Ok(fid)
2022    }
2023
2024    /// Subscribe the self-kill observer onto a loader-started registration
2025    /// fiber.
2026    ///
2027    /// The kernel's [`crate::Fiber::dispose`] marks the fiber disposed and
2028    /// fans out to state observers synchronously; the observer below fires
2029    /// on that transition and consults the [`LoaderOps`] operating flag:
2030    /// when NO loader window is open, the dispose came from the plugin
2031    /// itself (self-kill) and the entry is persisted `disabled = true`.
2032    /// Loader-driven disposals (retire/reconcile windows) never persist.
2033    fn watch_entry_fiber(
2034        ops: &std::sync::Arc<LoaderOps>,
2035        fiber: &std::sync::Arc<crate::Fiber>,
2036        entry_id: &str,
2037    ) {
2038        let ops_ref = std::sync::Arc::downgrade(ops);
2039        let entry = entry_id.to_string();
2040        // The observer MUST NOT call back into the fiber (kernel contract);
2041        // it only reads its own dedup marker plus the shared operating flag.
2042        let handle = fiber.subscribe_state(Box::new(move |state| {
2043            let Some(ops) = ops_ref.upgrade() else {
2044                return;
2045            };
2046            if !ops.in_loader_window()
2047                && matches!(
2048                    state,
2049                    crate::FiberState::Unloading { .. } | crate::FiberState::Inactive { .. }
2050                )
2051            {
2052                ops.persist_self_kill(&entry);
2053            }
2054        }));
2055        // Deliberately drop the cancellation handle: subscriptions live as
2056        // long as their fiber, and dropping it merely flags the observer for
2057        // cleanup at the next state fan-out — disposal of the fiber itself
2058        // ends its lifetime.
2059        drop(handle);
2060    }
2061}
2062
2063/// Shared tail of the verified hot-swap paths ([`Loader::rebuild_fiber_verified`]
2064/// and [`Loader::replace_provider`]): bridge → dispose old → promote → fresh
2065/// registration fiber.
2066///
2067/// Ordering guarantees the zero-absence-window invariant:
2068/// 1. Intercept overrides for every replaced TypeId are installed FIRST, so
2069///    lookups resolve to the new instances immediately.
2070/// 2. The old fiber is disposed directly (bypassing the public
2071///    guarded-withdrawal check in [`Context::remove`] on purpose): resolution
2072///    stays continuous by construction because the bridge already serves the
2073///    new values while the disposal undos clear the stale store entries.
2074/// 3. Bridge values are promoted into the store peek-before-remove (store
2075///    insert precedes intercept removal; intercept is consulted first), so no
2076///    lookup ever observes an empty slot.
2077/// 4. Types the new build introduces beyond what it replaces are added from
2078///    the scratch context.
2079/// 5. A fresh `Active` registration fiber is tracked and realm-registered;
2080///    the caller journals the swap outcome against its returned fiber id.
2081struct SwapPromotion<'a> {
2082    ctx: &'a Arc<crate::Context>,
2083    registry: &'a crate::RegistryService,
2084    scratch: &'a Arc<crate::Context>,
2085    /// Epoch label for the new registration fiber (`Active { epoch }`).
2086    epoch: &'a str,
2087    /// Optional entry-intercept overlay preserved exactly as
2088    /// `instantiate_entry` would have installed it (rebuild path only).
2089    intercept_overlay: Option<&'a HashMap<String, serde_json::Value>>,
2090    /// Every TypeId freshly built in the scratch context.
2091    built: &'a [TypeId],
2092    /// The subset of `built` currently served by the root context — the
2093    /// types this swap replaces.
2094    replaced: &'a [TypeId],
2095    old_fiber: Arc<crate::Fiber>,
2096    old_fid: crate::FiberId,
2097}
2098
2099impl SwapPromotion<'_> {
2100    async fn run(&self) -> crate::FiberId {
2101        // Preserve the entry-intercept overlay exactly as instantiate_entry
2102        // would have installed it.
2103        if let Some(overlay) = self.intercept_overlay.filter(|o| !o.is_empty()) {
2104            self.ctx.bind_intercept(EntryIntercept(overlay.clone()));
2105        }
2106
2107        // Bridge: intercept overrides win over store lookups, so installing
2108        // here makes the new instances resolvable instantly.
2109        for tid in self.replaced {
2110            if let Some(any) = self.scratch.get_untyped(*tid) {
2111                self.ctx.bind_intercept_untyped(*tid, any);
2112            }
2113        }
2114
2115        // Retire the old registration fiber: its undos clear the stale store
2116        // entries while the bridge keeps serving the new values. This is the
2117        // deliberate guarded-withdrawal bypass documented on both callers:
2118        // consumers never lose resolution, which is exactly the condition the
2119        // guard exists to protect.
2120        // A bounded-transition failure here means a hung plugin apply kept
2121        // the old fiber's inertia guard; the swap continues regardless — the
2122        // bridge already serves the new values, so surfacing the error would
2123        // only roll back a cutover that is already live.
2124        let _ = self.old_fiber.dispose().await;
2125        self.registry.remove(self.old_fid);
2126
2127        // Promote bridge values into the store. Peek-before-remove keeps every
2128        // lookup satisfied at every instant (store insert precedes intercept
2129        // removal, and intercept is consulted first).
2130        for tid in self.replaced {
2131            if let Some(any) = self.ctx.peek_intercept_untyped(*tid) {
2132                // A previously-promoted swap carries NO disposal undo
2133                // (`provide_untyped` bypasses the undo stack), so the retired
2134                // fiber cannot clear it. Take any such stale entry first;
2135                // the bridge stays up until the new value is inserted, so
2136                // lookups never observe a gap.
2137                self.ctx.take_untyped(*tid);
2138                let _ = self.ctx.provide_untyped(*tid, any);
2139                self.ctx.remove_intercept_untyped(*tid);
2140            }
2141        }
2142        // Types the new build introduces that the old one did not provide are
2143        // simply added to the store.
2144        for tid in self.built {
2145            if self.replaced.contains(tid) || self.ctx.get_untyped(*tid).is_some() {
2146                continue;
2147            }
2148            if let Some(any) = self.scratch.get_untyped(*tid) {
2149                let _ = self.ctx.provide_untyped(*tid, any);
2150            }
2151        }
2152
2153        // Fresh registration fiber owns the swapped-in provider.
2154        let fiber = std::sync::Arc::new(crate::Fiber::new());
2155        fiber.set_state(crate::FiberState::Active {
2156            epoch: self.epoch.to_string(),
2157        });
2158        let new_fid = self.registry.track_fiber(fiber);
2159        self.registry.track_fiber_in_realm(new_fid, self.ctx);
2160        new_fid
2161    }
2162}
2163
2164#[cfg(test)]
2165mod tests {
2166    use super::*;
2167    use crate::Context;
2168    use serde_json::json;
2169    use std::sync::Arc;
2170
2171    #[test]
2172    fn entry_json_round_trip() {
2173        let entry = Entry {
2174            id: "tool:calc".into(),
2175            plugin: "CalculatorService".into(),
2176            config: json!({"precision": 2}),
2177            disabled: false,
2178            isolate: Some("tenant:acme".into()),
2179            intercept: HashMap::new(),
2180                    position: None,
2181        };
2182        let s = serde_json::to_string(&entry).unwrap();
2183        let back: Entry = serde_json::from_str(&s).unwrap();
2184        assert_eq!(entry, back);
2185    }
2186
2187    #[test]
2188    fn entry_tree_json_round_trip() {
2189        let tree = EntryTree(vec![
2190            Entry {
2191                id: "a".into(),
2192                plugin: "Foo".into(),
2193                config: json!({"x": 1}),
2194                disabled: false,
2195                isolate: None,
2196                intercept: HashMap::new(),
2197                            position: None,
2198            },
2199            Entry {
2200                id: "b".into(),
2201                plugin: "Bar".into(),
2202                config: json!(null),
2203                disabled: true,
2204                isolate: None,
2205                intercept: HashMap::new(),
2206                            position: None,
2207            },
2208        ]);
2209        let s = serde_json::to_string(&tree).unwrap();
2210        let back: EntryTree = serde_json::from_str(&s).unwrap();
2211        assert_eq!(tree, back);
2212        let pretty = tree.to_json_pretty().unwrap();
2213        let back2 = EntryTree::from_json(&pretty).unwrap();
2214        assert_eq!(tree, back2);
2215    }
2216
2217    #[test]
2218    fn reconcile_config_change() {
2219        let cur = EntryTree(vec![Entry {
2220            id: "a".into(),
2221            plugin: "Foo".into(),
2222            config: json!({"v": 1}),
2223            disabled: false,
2224            isolate: None,
2225            intercept: HashMap::new(),
2226                    position: None,
2227        }]);
2228        let des = EntryTree(vec![Entry {
2229            id: "a".into(),
2230            plugin: "Foo".into(),
2231            config: json!({"v": 2}),
2232            disabled: false,
2233            isolate: None,
2234            intercept: HashMap::new(),
2235                    position: None,
2236        }]);
2237        let loader = Loader::new();
2238        let acts = loader.reconcile(&cur, &des);
2239        assert_eq!(acts.len(), 1);
2240        assert!(matches!(acts[0], LoaderAction::UpdateConfig { .. }));
2241    }
2242
2243    #[test]
2244    fn reconcile_disabled_toggle() {
2245        let cur = EntryTree(vec![Entry {
2246            id: "a".into(),
2247            plugin: "Foo".into(),
2248            config: json!(null),
2249            disabled: false,
2250            isolate: None,
2251            intercept: HashMap::new(),
2252                    position: None,
2253        }]);
2254        let des = EntryTree(vec![Entry {
2255            id: "a".into(),
2256            plugin: "Foo".into(),
2257            config: json!(null),
2258            disabled: true,
2259            isolate: None,
2260            intercept: HashMap::new(),
2261                    position: None,
2262        }]);
2263        let loader = Loader::new();
2264        assert!(matches!(
2265            loader.reconcile(&cur, &des)[0],
2266            LoaderAction::Retire { .. }
2267        ));
2268        assert!(matches!(
2269            loader.reconcile(&des, &cur)[0],
2270            LoaderAction::Begin { .. }
2271        ));
2272    }
2273
2274    #[test]
2275    fn reconcile_plugin_change_rebuild() {
2276        let cur = EntryTree(vec![Entry {
2277            id: "a".into(),
2278            plugin: "Foo".into(),
2279            config: json!(null),
2280            disabled: false,
2281            isolate: None,
2282            intercept: HashMap::new(),
2283                    position: None,
2284        }]);
2285        let des = EntryTree(vec![Entry {
2286            id: "a".into(),
2287            plugin: "Bar".into(),
2288            config: json!(null),
2289            disabled: false,
2290            isolate: None,
2291            intercept: HashMap::new(),
2292                    position: None,
2293        }]);
2294        let loader = Loader::new();
2295        assert!(matches!(
2296            loader.reconcile(&cur, &des)[0],
2297            LoaderAction::RebuildFiber { .. }
2298        ));
2299    }
2300
2301    #[test]
2302    fn reconcile_isolate_or_intercept_change_rebuilds_fiber() {
2303        let cur = EntryTree(vec![Entry {
2304            id: "a".into(),
2305            plugin: "Foo".into(),
2306            config: json!(null),
2307            disabled: false,
2308            isolate: None,
2309            intercept: HashMap::new(),
2310                    position: None,
2311        }]);
2312        let des_isolate = EntryTree(vec![Entry {
2313            id: "a".into(),
2314            plugin: "Foo".into(),
2315            config: json!(null),
2316            disabled: false,
2317            isolate: Some("tenant:acme".into()),
2318            intercept: HashMap::new(),
2319                    position: None,
2320        }]);
2321        let loader = Loader::new();
2322        assert!(matches!(
2323            loader.reconcile(&cur, &des_isolate)[0],
2324            LoaderAction::RebuildFiber { .. }
2325        ));
2326
2327        let mut intercept = HashMap::new();
2328        intercept.insert("k".into(), json!(1));
2329        let des_intercept = EntryTree(vec![Entry {
2330            id: "a".into(),
2331            plugin: "Foo".into(),
2332            config: json!(null),
2333            disabled: false,
2334            isolate: None,
2335            intercept,
2336                    position: None,
2337        }]);
2338        assert!(matches!(
2339            loader.reconcile(&cur, &des_intercept)[0],
2340            LoaderAction::RebuildFiber { .. }
2341        ));
2342    }
2343
2344    #[test]
2345    fn test_load_from_file() {
2346        let dir = tempfile::tempdir().unwrap();
2347        let path = dir.path().join("entries.toml");
2348        std::fs::write(
2349            &path,
2350            r#"
2351[[entry]]
2352id = "calc"
2353plugin = "CalculatorService"
2354disabled = false
2355
2356[entry.config]
2357
2358[[entry]]
2359id = "events"
2360plugin = "EventsService"
2361disabled = true
2362
2363[entry.config]
2364"#,
2365        )
2366        .unwrap();
2367
2368        let tree = Loader::load_from_file(&path).unwrap();
2369        assert_eq!(tree.0.len(), 2);
2370        assert_eq!(tree.0[0].id, "calc");
2371        assert_eq!(tree.0[0].plugin, "CalculatorService");
2372        assert!(!tree.0[0].disabled);
2373        assert_eq!(tree.0[1].id, "events");
2374        assert!(tree.0[1].disabled);
2375    }
2376
2377    #[test]
2378    fn test_reconcile_from_loaded_file() {
2379        let dir = tempfile::tempdir().unwrap();
2380        let path = dir.path().join("entries.toml");
2381        std::fs::write(
2382            &path,
2383            r#"
2384[[entry]]
2385id = "svc1"
2386plugin = "PluginA"
2387disabled = false
2388
2389[entry.config]
2390"#,
2391        )
2392        .unwrap();
2393
2394        let desired = Loader::load_from_file(&path).unwrap();
2395        let current = EntryTree(vec![]);
2396        let loader = Loader::new();
2397        let actions = loader.reconcile(&current, &desired);
2398        // New entry should produce a Begin action
2399        assert!(!actions.is_empty());
2400        assert!(matches!(actions[0], LoaderAction::Begin { .. }));
2401    }
2402
2403    #[test]
2404    fn loader_journal_upsert_and_get() {
2405        let journal = LoaderJournal::new();
2406        assert!(journal.is_empty());
2407        journal.upsert("svc:alpha", "AlphaService", json!({"v": 1}), Some(7));
2408        assert_eq!(journal.len(), 1);
2409        let rec = journal.get("svc:alpha").expect("record present");
2410        assert_eq!(rec.plugin, "AlphaService");
2411        assert_eq!(rec.config, json!({"v": 1}));
2412        assert_eq!(rec.fiber_id, Some(7));
2413        assert_eq!(rec.generation, 1);
2414    }
2415
2416    #[test]
2417    fn retire_clears_record_and_bumps_generation_tracking() {
2418        let journal = LoaderJournal::new();
2419        journal.upsert("svc:beta", "BetaService", json!({"v": 1}), Some(11));
2420
2421        // Retire removes the record entirely.
2422        let removed = journal
2423            .retire("svc:beta")
2424            .expect("record present before retire");
2425        assert_eq!(removed.plugin, "BetaService");
2426        assert!(journal.get("svc:beta").is_none());
2427        assert!(journal.is_empty());
2428
2429        // A later upsert for the same id starts a fresh generation, so the
2430        // previous record is not re-born at its old generation.
2431        journal.upsert("svc:beta", "BetaService", json!({"v": 2}), Some(12));
2432        let rec = journal.get("svc:beta").unwrap();
2433        assert_eq!(rec.fiber_id, Some(12));
2434        assert_eq!(rec.generation, 1);
2435    }
2436
2437    #[test]
2438    fn update_config_bumps_generation_and_stores_new_config() {
2439        let journal = LoaderJournal::new();
2440        journal.upsert("svc:gamma", "GammaService", json!({"v": 1}), Some(21));
2441        assert_eq!(journal.get("svc:gamma").unwrap().generation, 1);
2442
2443        let updated = journal
2444            .update_config("svc:gamma", json!({"v": 2}), None)
2445            .expect("record exists");
2446        assert_eq!(updated.config, json!({"v": 2}));
2447        assert_eq!(updated.generation, 2);
2448
2449        // Config persisted in the journal.
2450        let rec = journal.get("svc:gamma").unwrap();
2451        assert_eq!(rec.config, json!({"v": 2}));
2452        assert_eq!(rec.generation, 2);
2453        // fiber_id unchanged when not explicitly updated.
2454        assert_eq!(rec.fiber_id, Some(21));
2455    }
2456
2457    #[test]
2458    fn update_config_missing_id_is_noop() {
2459        let journal = LoaderJournal::new();
2460        assert!(journal
2461            .update_config("svc:ghost", json!({"v": 1}), None)
2462            .is_none());
2463        assert!(journal.is_empty());
2464    }
2465
2466    #[test]
2467    fn execute_action_retire_clears_journal_record() {
2468        let ctx = Context::new_root();
2469        let journal = ctx.provide(LoaderJournal::new());
2470        journal.upsert("svc:delta", "DeltaService", json!({"v": 1}), Some(31));
2471
2472        Loader::execute_action(
2473            &LoaderAction::Retire {
2474                id: "svc:delta".into(),
2475            },
2476            &ctx,
2477        );
2478        assert!(journal.get("svc:delta").is_none());
2479        assert!(journal.is_empty());
2480    }
2481
2482    #[test]
2483    fn execute_action_update_config_bumps_generation_without_fiber() {
2484        let ctx = Context::new_root();
2485        let journal = ctx.provide(LoaderJournal::new());
2486        journal.upsert("svc:epsilon", "EpsilonService", json!({"v": 1}), Some(41));
2487
2488        Loader::execute_action(
2489            &LoaderAction::UpdateConfig {
2490                id: "svc:epsilon".into(),
2491                new_config: json!({"v": 2}),
2492            },
2493            &ctx,
2494        );
2495
2496        // No RegistryService / live fiber was resolvable, so the update is
2497        // journal-only, but the record must still advance generation and store
2498        // the new config.
2499        let rec = journal.get("svc:epsilon").expect("record retained");
2500        assert_eq!(rec.config, json!({"v": 2}));
2501        assert_eq!(rec.generation, 2);
2502        assert_eq!(rec.fiber_id, Some(41));
2503    }
2504
2505    #[test]
2506    fn execute_action_update_config_without_journal_is_log_only() {
2507        let ctx = Context::new_root();
2508        // No registry, no journal — arm must not panic and must stay log-only.
2509        Loader::execute_action(
2510            &LoaderAction::UpdateConfig {
2511                id: "svc:zeta".into(),
2512                new_config: json!({"v": 2}),
2513            },
2514            &ctx,
2515        );
2516        assert!(ctx.get::<LoaderJournal>().is_none());
2517    }
2518
2519    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2520    async fn instantiate_writes_journal_record_and_update_reaches_live_fiber() {
2521        use crate::RegistryService;
2522
2523        let ctx = Context::new_root();
2524        ctx.provide(LoaderJournal::new());
2525        ctx.provide(RegistryService::new());
2526        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
2527
2528        // A small plugin factory that provides a service via Context::plugin,
2529        // mirroring the production factory pattern.
2530        #[derive(Debug)]
2531        struct Svc;
2532        impl Service for Svc {}
2533
2534        plugin_registry.register(
2535            "SvcFactory",
2536            Arc::new(|ctx, config| {
2537                let _ = config;
2538                let future = ctx.plugin(Svc);
2539                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
2540            }),
2541        );
2542
2543        let fid = Loader::instantiate(&ctx, "SvcFactory", &json!({"v": 1}), "svc:theta")
2544            .expect("instantiate should succeed");
2545        assert!(fid > 0);
2546
2547        let journal = ctx.get::<LoaderJournal>().expect("journal present");
2548        let rec = journal
2549            .get("svc:theta")
2550            .expect("instantiate wrote journal record");
2551        assert_eq!(rec.plugin, "SvcFactory");
2552        assert_eq!(rec.config, json!({"v": 1}));
2553        assert_eq!(rec.fiber_id, Some(fid));
2554        assert_eq!(rec.generation, 1);
2555
2556        // UpdateConfig with the live fiber resolves through RegistryService and
2557        // drives Fiber::update — repeat it against the same ctx.
2558        Loader::execute_action(
2559            &LoaderAction::UpdateConfig {
2560                id: "svc:theta".into(),
2561                new_config: json!({"v": 2}),
2562            },
2563            &ctx,
2564        );
2565        let rec = journal.get("svc:theta").expect("record retained");
2566        assert_eq!(rec.config, json!({"v": 2}));
2567        assert_eq!(rec.generation, 2);
2568    }
2569
2570    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2571    async fn instantiate_entry_applies_isolate_and_intercept() {
2572        use crate::RegistryService;
2573        use std::any::TypeId;
2574
2575        let ctx = Context::new_root();
2576        ctx.provide(LoaderJournal::new());
2577        ctx.provide(RegistryService::new());
2578        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
2579
2580        #[derive(Debug)]
2581        struct Svc(String);
2582        impl Service for Svc {}
2583
2584        plugin_registry.register(
2585            "SvcFactory",
2586            Arc::new(|ctx, config| {
2587                let label = config
2588                    .get("mark")
2589                    .and_then(|v| v.as_str())
2590                    .unwrap_or("none")
2591                    .to_string();
2592                let future = ctx.plugin(Svc(label));
2593                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
2594            }),
2595        );
2596
2597        let mut intercept = HashMap::new();
2598        intercept.insert("timeout".into(), json!(5));
2599        let entry = Entry {
2600            id: "svc:acme".into(),
2601            plugin: "SvcFactory".into(),
2602            config: json!({"mark": "acme"}),
2603            disabled: false,
2604            isolate: Some("tenant:acme".into()),
2605            intercept,
2606                    position: None,
2607        };
2608        Loader::instantiate_entry(&ctx, &entry).expect("instantiate_entry");
2609
2610        assert_eq!(
2611            ctx.isolate_label(TypeId::of::<Svc>()).as_deref(),
2612            Some("tenant:acme")
2613        );
2614        let isolated = ctx
2615            .get_isolated::<Svc>("tenant:acme")
2616            .expect("isolated Svc");
2617        assert_eq!(isolated.0, "acme");
2618        assert!(ctx.get::<Svc>().is_some(), "boot get still sees the plugin");
2619        let overlay = ctx.get::<EntryIntercept>().expect("EntryIntercept bound");
2620        assert_eq!(overlay.0.get("timeout"), Some(&json!(5)));
2621    }
2622
2623    #[allow(dead_code)]
2624    fn _assert_exports() {
2625        let _: AppliedAction;
2626    }
2627
2628    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2629    async fn apply_begins_instantiate_and_journals() {
2630        use crate::RegistryService;
2631
2632        let ctx = Context::new_root();
2633        let journal = LoaderJournal::provide_new(&ctx);
2634        ctx.provide(RegistryService::new());
2635        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
2636
2637        #[derive(Debug)]
2638        struct SvcA(u64);
2639        impl Service for SvcA {}
2640        #[derive(Debug)]
2641        struct SvcB(u64);
2642        impl Service for SvcB {}
2643
2644        plugin_registry.register(
2645            "FactoryA",
2646            Arc::new(|ctx, _cfg| {
2647                let future = ctx.plugin(SvcA(0));
2648                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
2649            }),
2650        );
2651        plugin_registry.register(
2652            "FactoryB",
2653            Arc::new(|ctx, _cfg| {
2654                let future = ctx.plugin(SvcB(0));
2655                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
2656            }),
2657        );
2658
2659        let desired = EntryTree(vec![
2660            Entry {
2661                id: "a:one".into(),
2662                plugin: "FactoryA".into(),
2663                config: json!({}),
2664                disabled: false,
2665                isolate: None,
2666                intercept: HashMap::new(),
2667                            position: None,
2668            },
2669            Entry {
2670                id: "b:two".into(),
2671                plugin: "FactoryB".into(),
2672                config: json!({}),
2673                disabled: false,
2674                isolate: None,
2675                intercept: HashMap::new(),
2676                            position: None,
2677            },
2678        ]);
2679        let mut current = EntryTree(vec![]);
2680
2681        let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
2682        assert_eq!(actions.len(), 2);
2683        assert!(actions
2684            .iter()
2685            .all(|a| a.action == "begin" && a.status.is_ok()));
2686        assert_eq!(current.0.len(), 2);
2687        assert!(ctx.get::<SvcA>().is_some());
2688        assert!(ctx.get::<SvcB>().is_some());
2689        let rec_a = journal.get("a:one").expect("journal has a");
2690        assert!(rec_a.fiber_id.is_some());
2691
2692        // Retire `a`, keep `b`.
2693        let desired2 = EntryTree(vec![desired.0[1].clone()]);
2694        let actions = Loader::apply(&ctx, &mut current, &desired2, &journal).await;
2695        assert_eq!(actions[0].action, "retire");
2696        assert_eq!(actions[0].status, Ok(()));
2697        assert!(ctx.get::<SvcA>().is_none(), "retired fiber disposed");
2698        assert!(ctx.get::<SvcB>().is_some(), "kept entry still live");
2699        assert!(journal.get("a:one").is_none());
2700    }
2701
2702    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2703    async fn apply_aborts_on_first_failure_and_rolls_back() {
2704        use crate::RegistryService;
2705
2706        // Staged batch semantics (two-phase apply): the FIRST failing step
2707        // aborts the whole batch. Entries applied before it are reverted and
2708        // the failing entry is named in its error; `current` stays unchanged
2709        // so a retry re-diffs cleanly.
2710        let ctx = Context::new_root();
2711        let journal = LoaderJournal::provide_new(&ctx);
2712        ctx.provide(RegistryService::new());
2713        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
2714
2715        #[derive(Debug)]
2716        struct Good(std::sync::atomic::AtomicU64);
2717        impl Service for Good {}
2718
2719        plugin_registry.register(
2720            "GoodFactory",
2721            Arc::new(|ctx, cfg| {
2722                let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
2723                let future = ctx.plugin(Good(std::sync::atomic::AtomicU64::new(v)));
2724                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
2725            }),
2726        );
2727        plugin_registry.register(
2728            "LateGoodFactory",
2729            Arc::new(|ctx, _cfg| {
2730                let future = ctx.plugin(Good(std::sync::atomic::AtomicU64::new(99)));
2731                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
2732            }),
2733        );
2734        // No factory for "GhostFactory".
2735
2736        let desired = EntryTree(vec![
2737            Entry {
2738                id: "good:one".into(),
2739                plugin: "GhostFactory".into(),
2740                config: json!({}),
2741                disabled: false,
2742                isolate: None,
2743                intercept: HashMap::new(),
2744                            position: None,
2745            },
2746            Entry {
2747                id: "good:two".into(),
2748                plugin: "GoodFactory".into(),
2749                config: json!({"v": 1}),
2750                disabled: false,
2751                isolate: None,
2752                intercept: HashMap::new(),
2753                            position: None,
2754            },
2755        ]);
2756        let mut current = EntryTree(vec![]);
2757
2758        // Batch where the FIRST dependency-class step fails (unknown factory):
2759        // nothing was applied before the abort, so no sibling may survive.
2760        let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
2761        let failed = actions
2762            .iter()
2763            .find(|a| a.id == "good:one")
2764            .expect("failing entry named in results");
2765        assert!(
2766            failed.status.is_err(),
2767            "unknown factory must fail its action"
2768        );
2769        assert_eq!(actions.len(), 1, "abort-on-first-failure: one outcome only");
2770        assert!(
2771            !actions.iter().any(|a| a.id == "good:two"),
2772            "entries after the failing step are never applied"
2773        );
2774        assert!(
2775            ctx.get::<Good>().is_none(),
2776            "no sibling instantiated when the first step already failed"
2777        );
2778        assert!(
2779            current.0.is_empty(),
2780            "current tree must stay unchanged when any action failed"
2781        );
2782
2783        // Now a batch whose LATER step fails after an earlier Begin applied:
2784        // the rollback must dispose the earlier entry so nothing survives.
2785        journal.upsert("seed", "GoodFactory", json!({"v": 0}), None);
2786        let desired_late = EntryTree(vec![
2787            Entry {
2788                id: "good:first".into(),
2789                plugin: "GoodFactory".into(),
2790                config: json!({"v": 7}),
2791                disabled: false,
2792                isolate: None,
2793                intercept: HashMap::new(),
2794                            position: None,
2795            },
2796            Entry {
2797                id: "good:last".into(),
2798                plugin: "GhostFactory".into(),
2799                config: json!({}),
2800                disabled: false,
2801                isolate: None,
2802                intercept: HashMap::new(),
2803                            position: None,
2804            },
2805            Entry {
2806                id: "good:never".into(),
2807                plugin: "LateGoodFactory".into(),
2808                config: json!({}),
2809                disabled: false,
2810                isolate: None,
2811                intercept: HashMap::new(),
2812                            position: None,
2813            },
2814        ]);
2815        let actions =
2816            Loader::apply(&ctx, &mut current, &desired_late, &journal).await;
2817        let failed = actions
2818            .iter()
2819            .find(|a| a.id == "good:last")
2820            .expect("mid-batch failure named");
2821        assert!(failed.status.is_err());
2822        assert!(
2823            failed.status.as_ref().unwrap_err().contains("no factory registered"),
2824            "error names the cause: {:?}",
2825            failed.status
2826        );
2827        assert!(
2828            !actions.iter().any(|a| a.id == "good:never"),
2829            "entries past the failure never ran"
2830        );
2831        assert!(
2832            ctx.get::<Good>().is_none(),
2833            "rolled back: the entry applied before the failure is disposed"
2834        );
2835        assert!(
2836            journal.get("good:first").is_none(),
2837            "rollback retired the began entry's journal record"
2838        );
2839        assert!(
2840            current.0.is_empty(),
2841            "current stays at the prior tree after a rolled-back batch"
2842        );
2843    }
2844
2845    // --- verified hot-swap (item #3) ---
2846
2847    /// Shared service type both swap plugins provide.
2848    #[derive(Debug)]
2849    struct Swappable(std::sync::atomic::AtomicU64);
2850    impl Service for Swappable {}
2851
2852    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2853    async fn rebuild_same_type_verified_swap() {
2854        use crate::RegistryService;
2855        use std::sync::atomic::Ordering;
2856
2857        let ctx = Context::new_root();
2858        let journal = LoaderJournal::provide_new(&ctx);
2859        ctx.provide(RegistryService::new());
2860        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
2861
2862        // Two factories providing the SAME service TypeId; the counter marks
2863        // which instance is live so we can observe continuity across the swap.
2864        plugin_registry.register(
2865            "SwapFactoryA",
2866            Arc::new(|ctx: &Arc<crate::Context>, _cfg| {
2867                let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(1)));
2868                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
2869            }),
2870        );
2871        plugin_registry.register(
2872            "SwapFactoryB",
2873            Arc::new(|ctx: &Arc<crate::Context>, _cfg| {
2874                let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(2)));
2875                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
2876            }),
2877        );
2878
2879        let desired_a = EntryTree(vec![Entry {
2880            id: "swap".into(),
2881            plugin: "SwapFactoryA".into(),
2882            config: json!({}),
2883            disabled: false,
2884            isolate: None,
2885            intercept: HashMap::new(),
2886                    position: None,
2887        }]);
2888        let mut current = EntryTree(vec![]);
2889        let actions = Loader::apply(&ctx, &mut current, &desired_a, &journal).await;
2890        assert_eq!(actions[0].action, "begin");
2891        assert!(actions[0].status.is_ok());
2892        assert_eq!(actions[0].verified, true);
2893        let svc = ctx.get::<Swappable>().expect("initial provider");
2894        assert_eq!(svc.0.load(Ordering::SeqCst), 1);
2895
2896        // Plugin change with the same Provides TypeId -> RebuildFiber, and the
2897        // live service must stay resolvable across the whole apply (probed
2898        // from a concurrent task while the swap runs on this one).
2899        let desired_b = EntryTree(vec![Entry {
2900            id: "swap".into(),
2901            plugin: "SwapFactoryB".into(),
2902            config: json!({}),
2903            disabled: false,
2904            isolate: None,
2905            intercept: HashMap::new(),
2906                    position: None,
2907        }]);
2908        let ctx_probe = ctx.clone();
2909        let prober = tokio::spawn(async move {
2910            for _ in 0..200 {
2911                if ctx_probe.get::<Swappable>().is_none() {
2912                    return false;
2913                }
2914                tokio::task::yield_now().await;
2915            }
2916            true
2917        });
2918        let actions = Loader::apply(&ctx, &mut current, &desired_b, &journal).await;
2919        assert_eq!(actions[0].action, "rebuild-fiber");
2920        assert!(actions[0].status.is_ok(), "rebuild ok");
2921        assert_eq!(actions[0].verified, true, "same-type swap must be verified");
2922
2923        let continuous = prober.await.expect("prober task");
2924        assert!(continuous, "service must stay resolvable during swap");
2925
2926        // New instance is live and owned by a fresh Active fiber.
2927        let svc = ctx.get::<Swappable>().expect("swapped provider");
2928        assert_eq!(svc.0.load(Ordering::SeqCst), 2);
2929        let rec = journal.get("swap").expect("journal record");
2930        let fid = rec.fiber_id.expect("fiber recorded");
2931        let registry = ctx.get::<RegistryService>().unwrap();
2932        assert!(matches!(
2933            registry.get_fiber(fid).unwrap().state(),
2934            crate::FiberState::Active { .. }
2935        ));
2936        assert_eq!(current.0.len(), 1, "current tree advanced");
2937    }
2938
2939    /// UpdateConfig pre-flight: a factory that rejects the new config fails
2940    /// its action with the "config pre-flight failed" marker, the journal and
2941    /// live fiber stay untouched, and the OLD provider keeps serving.
2942    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2943    async fn bad_config_update_keeps_old_provider_serving() {
2944        use crate::RegistryService;
2945        use std::sync::atomic::Ordering;
2946
2947        let ctx = Context::new_root();
2948        let journal = LoaderJournal::provide_new(&ctx);
2949        ctx.provide(RegistryService::new());
2950        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
2951
2952        // Dual-mode factory: healthy instance for {"v": N}, hard failure for
2953        // {"fail": true}. Mirrors the KeeperFactory shape of the swap tests.
2954        plugin_registry.register(
2955            "PickyFactory",
2956            Arc::new(|ctx: &Arc<crate::Context>, cfg| {
2957                if cfg.get("fail").and_then(|x| x.as_bool()) == Some(true) {
2958                    return Err(crate::CordisError::Configuration(
2959                        "config rejected by factory".into(),
2960                    ));
2961                }
2962                let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
2963                let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(v)));
2964                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
2965            }),
2966        );
2967
2968        let entry_ok = Entry {
2969            id: "picky".into(),
2970            plugin: "PickyFactory".into(),
2971            config: json!({"v": 1}),
2972            disabled: false,
2973            isolate: None,
2974            intercept: HashMap::new(),
2975                    position: None,
2976        };
2977        let mut current = EntryTree(vec![]);
2978        Loader::apply(&ctx, &mut current, &EntryTree(vec![entry_ok]), &journal).await;
2979        let before = journal.get("picky").expect("journal record after begin");
2980        assert_eq!(
2981            ctx.get::<Swappable>().unwrap().0.load(Ordering::SeqCst),
2982            1,
2983            "old provider serving"
2984        );
2985
2986        // Config change to a REJECTED config → UpdateConfig action whose
2987        // pre-flight trial fails; old provider must keep serving.
2988        let desired_bad = EntryTree(vec![Entry {
2989            id: "picky".into(),
2990            plugin: "PickyFactory".into(),
2991            config: json!({"fail": true}),
2992            disabled: false,
2993            isolate: None,
2994            intercept: HashMap::new(),
2995                    position: None,
2996        }]);
2997        let actions = Loader::apply(&ctx, &mut current, &desired_bad, &journal).await;
2998        assert_eq!(actions[0].action, "update-config");
2999        assert!(actions[0].status.is_err(), "pre-flight failure reported");
3000        assert!(
3001            actions[0]
3002                .status
3003                .as_ref()
3004                .unwrap_err()
3005                .contains("config pre-flight failed"),
3006            "failure names the pre-flight marker, got {:?}",
3007            actions[0].status
3008        );
3009
3010        // Old provider fully intact; journal frozen (no generation bump, no
3011        // config overwrite); current tree unchanged so a retry re-diffs.
3012        assert!(
3013            ctx.get::<Swappable>().is_some(),
3014            "old provider kept serving"
3015        );
3016        assert_eq!(
3017            ctx.get::<Swappable>().unwrap().0.load(Ordering::SeqCst),
3018            1,
3019            "still the OLD instance value"
3020        );
3021        let after = journal.get("picky").expect("record retained");
3022        assert_eq!(after.generation, before.generation, "generation frozen");
3023        assert_eq!(after.config, json!({"v": 1}), "config not overwritten");
3024        let fid = before.fiber_id.expect("fiber tracked");
3025        assert!(matches!(
3026            ctx.get::<RegistryService>()
3027                .unwrap()
3028                .get_fiber(fid)
3029                .unwrap()
3030                .state(),
3031            crate::FiberState::Active { .. }
3032        ));
3033        assert_eq!(current.0[0].config, json!({"v": 1}), "tree unchanged");
3034
3035        // A HEALTHY config change still goes through end-to-end (the
3036        // pre-flight passes and Fiber::update re-applies).
3037        let desired_good = EntryTree(vec![Entry {
3038            id: "picky".into(),
3039            plugin: "PickyFactory".into(),
3040            config: json!({"v": 5}),
3041            disabled: false,
3042            isolate: None,
3043            intercept: HashMap::new(),
3044                    position: None,
3045        }]);
3046        let actions = Loader::apply(&ctx, &mut current, &desired_good, &journal).await;
3047        assert_eq!(actions[0].action, "update-config");
3048        assert!(actions[0].status.is_ok(), "healthy update applies");
3049        assert_eq!(current.0[0].config, json!({"v": 5}), "tree advanced");
3050        assert_eq!(
3051            journal.get("picky").unwrap().generation,
3052            before.generation + 1,
3053            "journal bumped once on success"
3054        );
3055    }
3056
3057    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3058    async fn rebuild_failure_keeps_old() {
3059        use crate::RegistryService;
3060
3061        let ctx = Context::new_root();
3062        let journal = LoaderJournal::provide_new(&ctx);
3063        ctx.provide(RegistryService::new());
3064        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3065
3066        #[derive(Debug)]
3067        struct Keeper(u64);
3068        impl Service for Keeper {}
3069
3070        plugin_registry.register(
3071            "KeeperFactory",
3072            Arc::new(|ctx: &Arc<crate::Context>, _cfg| {
3073                let fut = ctx.plugin(Keeper(1));
3074                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3075            }),
3076        );
3077        plugin_registry.register(
3078            "BrokenFactory",
3079            Arc::new(|_ctx: &Arc<crate::Context>, _cfg| {
3080                Err(crate::CordisError::Configuration(
3081                    "intentional swap failure".into(),
3082                ))
3083            }),
3084        );
3085
3086        let desired_ok = EntryTree(vec![Entry {
3087            id: "keep".into(),
3088            plugin: "KeeperFactory".into(),
3089            config: json!({}),
3090            disabled: false,
3091            isolate: None,
3092            intercept: HashMap::new(),
3093                    position: None,
3094        }]);
3095        let mut current = EntryTree(vec![]);
3096        Loader::apply(&ctx, &mut current, &desired_ok, &journal).await;
3097        assert!(ctx.get::<Keeper>().is_some(), "old provider live");
3098
3099        // A failing candidate must leave the old provider serving untouched.
3100        let desired_bad = EntryTree(vec![Entry {
3101            id: "keep".into(),
3102            plugin: "BrokenFactory".into(),
3103            config: json!({}),
3104            disabled: false,
3105            isolate: None,
3106            intercept: HashMap::new(),
3107                    position: None,
3108        }]);
3109        let actions = Loader::apply(&ctx, &mut current, &desired_bad, &journal).await;
3110        assert_eq!(actions[0].action, "rebuild-fiber");
3111        assert!(actions[0].status.is_err(), "failed trial reported");
3112        assert!(actions[0]
3113            .status
3114            .as_ref()
3115            .unwrap_err()
3116            .contains("intentional swap failure"));
3117        assert!(
3118            ctx.get::<Keeper>().is_some(),
3119            "old fiber still Active after failed rebuild"
3120        );
3121        // Current tree unchanged so retry re-diffs cleanly.
3122        assert_eq!(current.0[0].plugin, "KeeperFactory");
3123    }
3124
3125    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3126    async fn rebuild_without_tracked_fiber_reports_unverified() {
3127        use crate::RegistryService;
3128
3129        // Journal WITHOUT fiber ids simulates an entry whose registration was
3130        // never tracked: the fallback path must run and report verified=false.
3131        let ctx = Context::new_root();
3132        let journal = LoaderJournal::provide_new(&ctx);
3133        ctx.provide(RegistryService::new());
3134        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3135
3136        #[derive(Debug)]
3137        struct Fallback(u64);
3138        impl Service for Fallback {}
3139
3140        plugin_registry.register(
3141            "FallbackFactory",
3142            Arc::new(|ctx: &Arc<crate::Context>, _cfg| {
3143                let fut = ctx.plugin(Fallback(9));
3144                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3145            }),
3146        );
3147        // Seed the journal record by hand with no fiber id (as an untracked boot
3148        // would have left it).
3149        journal.upsert("fb", "FallbackFactory", json!({}), None);
3150
3151        let desired = EntryTree(vec![Entry {
3152            id: "fb".into(),
3153            plugin: "FallbackFactory".into(),
3154            config: json!({}),
3155            disabled: false,
3156            isolate: None,
3157            intercept: HashMap::new(),
3158                    position: None,
3159        }]);
3160        let mut current = EntryTree(vec![Entry {
3161            id: "fb".into(),
3162            plugin: "OtherPlugin".into(),
3163            config: json!({}),
3164            disabled: false,
3165            isolate: None,
3166            intercept: HashMap::new(),
3167                    position: None,
3168        }]);
3169        let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
3170        assert_eq!(actions[0].action, "rebuild-fiber");
3171        assert!(actions[0].status.is_ok());
3172        assert_eq!(actions[0].verified, false, "fallback is unverified");
3173        assert!(ctx.get::<Fallback>().is_some(), "entry instantiated");
3174    }
3175
3176    #[test]
3177    fn save_to_toml_file_round_trips_entries() {
3178        let dir = tempfile::tempdir().unwrap();
3179        let path = dir.path().join("entries.toml");
3180        let tree = EntryTree(vec![
3181            Entry {
3182                id: "tool:calc".into(),
3183                plugin: "CalculatorService".into(),
3184                config: json!({"precision": 2}),
3185                disabled: true,
3186                isolate: None,
3187                intercept: HashMap::new(),
3188                            position: None,
3189            },
3190            Entry {
3191                id: "svc:acme".into(),
3192                plugin: "PluginA".into(),
3193                config: json!({"x": 1}),
3194                disabled: false,
3195                isolate: Some("acme".into()),
3196                intercept: HashMap::new(),
3197                            position: None,
3198            },
3199        ]);
3200        tree.save_to_toml_file(&path).unwrap();
3201        let loaded = Loader::load_from_file(&path).unwrap();
3202        assert_eq!(tree, loaded);
3203    }
3204
3205    #[test]
3206    fn save_to_toml_file_leaves_no_temp_files() {
3207        let dir = tempfile::tempdir().unwrap();
3208        let path = dir.path().join("entries.toml");
3209        let tree = EntryTree(vec![Entry {
3210            id: "tool:calc".into(),
3211            plugin: "CalculatorService".into(),
3212            config: json!({}),
3213            disabled: false,
3214            isolate: None,
3215            intercept: HashMap::new(),
3216                    position: None,
3217        }]);
3218        // Two consecutive saves exercise both the create and rename-over
3219        // paths; neither may leave `.tmp-*` siblings behind.
3220        tree.save_to_toml_file(&path).unwrap();
3221        tree.save_to_toml_file(&path).unwrap();
3222        let mut leftovers: Vec<String> = std::fs::read_dir(dir.path())
3223            .unwrap()
3224            .filter_map(Result::ok)
3225            .map(|e| e.file_name().to_string_lossy().into_owned())
3226            .collect();
3227        leftovers.sort();
3228        assert_eq!(leftovers, vec!["entries.toml".to_string()]);
3229    }
3230
3231    #[test]
3232    fn save_to_toml_file_preserves_comment_header() {
3233        let dir = tempfile::tempdir().unwrap();
3234        let path = dir.path().join("entries.toml");
3235        std::fs::write(
3236            &path,
3237            r#"# Cordis plugin entries loaded at startup.
3238# Order matters.
3239
3240[[entry]]
3241id = "a"
3242plugin = "Foo"
3243
3244[entry.config]
3245
3246[[entry]]
3247id = "b"
3248plugin = "Bar"
3249
3250[entry.config]
3251"#,
3252        )
3253        .unwrap();
3254
3255        let mut tree = Loader::load_from_file(&path).unwrap();
3256        assert_eq!(tree.len(), 2);
3257        tree.0.push(Entry {
3258            id: "c".into(),
3259            plugin: "Baz".into(),
3260            config: json!({}),
3261            disabled: false,
3262            isolate: Some("acme".into()),
3263            intercept: HashMap::new(),
3264                    position: None,
3265        });
3266        tree.save_to_toml_file(&path).unwrap();
3267
3268        let raw = std::fs::read_to_string(&path).unwrap();
3269        let first_table = raw.find("[[entry]]").expect("serialized body present");
3270        let header = &raw[..first_table];
3271        assert!(
3272            header.contains("# Cordis plugin entries loaded at startup."),
3273            "first comment line must survive the round-trip"
3274        );
3275        assert!(
3276            header.contains("# Order matters."),
3277            "second comment line must survive the round-trip"
3278        );
3279
3280        let reloaded = Loader::load_from_file(&path).unwrap();
3281        assert_eq!(reloaded.len(), 3);
3282        assert_eq!(reloaded, tree);
3283    }
3284
3285    #[test]
3286    fn save_to_toml_file_empty_tree_writes_valid_toml() {
3287        let dir = tempfile::tempdir().unwrap();
3288        let path = dir.path().join("empty.toml");
3289        EntryTree::default().save_to_toml_file(&path).unwrap();
3290        let loaded = Loader::load_from_file(&path).unwrap();
3291        assert_eq!(loaded.len(), 0);
3292        assert!(loaded.is_empty());
3293    }
3294
3295    #[test]
3296    fn save_to_file_is_atomic_no_temp_residue() {
3297        let dir = tempfile::tempdir().unwrap();
3298        let path = dir.path().join("nested").join("entries.json");
3299        let tree = EntryTree(vec![Entry {
3300            id: "tool:calc".into(),
3301            plugin: "CalculatorService".into(),
3302            config: json!({"precision": 2}),
3303            disabled: false,
3304            isolate: None,
3305            intercept: HashMap::new(),
3306                    position: None,
3307        }]);
3308        // Parent directory does not exist yet — the save must create it.
3309        tree.save_to_file(path.to_str().unwrap()).unwrap();
3310        assert_eq!(
3311            EntryTree::load_from_file(path.to_str().unwrap()).unwrap(),
3312            tree,
3313            "content survives the temp+rename round-trip"
3314        );
3315        // Two consecutive saves exercise create + rename-over; neither may
3316        // leave `.tmp-*` siblings behind (rename consumed each temp).
3317        tree.save_to_file(path.to_str().unwrap()).unwrap();
3318        let mut leftovers: Vec<String> = std::fs::read_dir(dir.path())
3319            .unwrap()
3320            .filter_map(Result::ok)
3321            .map(|e| e.file_name().to_string_lossy().into_owned())
3322            .collect();
3323        leftovers.sort();
3324        assert_eq!(
3325            leftovers,
3326            vec!["nested".to_string()],
3327            "no *.tmp-* siblings may remain after a successful save"
3328        );
3329        let inner: Vec<String> = std::fs::read_dir(dir.path().join("nested"))
3330            .unwrap()
3331            .filter_map(Result::ok)
3332            .map(|e| e.file_name().to_string_lossy().into_owned())
3333            .collect();
3334        assert_eq!(inner, vec!["entries.json".to_string()]);
3335    }
3336
3337    #[test]
3338    fn save_to_file_consecutive_saves_succeed_with_distinct_temps() {
3339        let dir = tempfile::tempdir().unwrap();
3340        let path = dir.path().join("entries.json");
3341        let tree = EntryTree::default();
3342        // Each save consumes a fresh pid+nonce temp name; both must succeed
3343        // (a colliding name would make the second rename target already
3344        // gone / interleaved with the first).
3345        tree.save_to_file(path.to_str().unwrap()).unwrap();
3346        tree.save_to_file(path.to_str().unwrap()).unwrap();
3347        assert_eq!(
3348            EntryTree::load_from_file(path.to_str().unwrap()).unwrap(),
3349            EntryTree::default()
3350        );
3351        // Nonce monotonicity: distinct increments, never reused.
3352        let a = next_save_nonce();
3353        let b = next_save_nonce();
3354        assert_ne!(a, b, "nonce must be monotonic across calls");
3355    }
3356
3357    // --- dependency-cycle detection (round-7 wiring of cycles.rs) ---
3358
3359    /// Mutual-inject pair: A declares an inject on B's provided type and vice
3360    /// versa, mirroring the declare_inject pattern from
3361    /// `crates/ares-agent/src/plugins.rs`.
3362    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3363    async fn cycle_detection_finds_mutual_declared_injects() {
3364        use crate::cycles::CycleLedger;
3365        use crate::{Plugin, RegistryService};
3366
3367        #[derive(Debug)]
3368        struct SvcA(u32);
3369        impl Service for SvcA {}
3370        #[derive(Debug)]
3371        struct SvcB(u32);
3372        impl Service for SvcB {}
3373
3374        struct PluginA;
3375        impl Plugin for PluginA {
3376            type Config = ();
3377            type Provides = SvcA;
3378            fn apply(&self, ctx: &Arc<Context>, _cfg: ()) -> Result<Arc<SvcA>, crate::CordisError> {
3379                Ok(ctx.provide(SvcA(1)))
3380            }
3381        }
3382
3383        struct PluginB;
3384        impl Plugin for PluginB {
3385            type Config = ();
3386            type Provides = SvcB;
3387            fn apply(&self, ctx: &Arc<Context>, _cfg: ()) -> Result<Arc<SvcB>, crate::CordisError> {
3388                Ok(ctx.provide(SvcB(2)))
3389            }
3390        }
3391
3392        let ctx = Context::new_root();
3393        ctx.provide(crate::LoaderJournal::new());
3394        ctx.provide(RegistryService::new());
3395        ctx.provide(CycleLedger::new());
3396        let registry = ctx.get::<RegistryService>().unwrap();
3397
3398        let fid_a = registry.plugin(&ctx, PluginA, ()).expect("register A");
3399        let fid_b = registry.plugin(&ctx, PluginB, ()).expect("register B");
3400        // Off the loader path the ledger must be fed explicitly — this mirrors
3401        // exactly what instantiate_entry records per fresh provide.
3402        let ledger = ctx.get::<CycleLedger>().unwrap();
3403        ledger.record_provider(std::any::TypeId::of::<SvcA>(), None, fid_a);
3404        ledger.record_provider(std::any::TypeId::of::<SvcB>(), None, fid_b);
3405        // The mutual inject declarations that make A and B permanently wait on
3406        // each other.
3407        registry.get_fiber(fid_a).unwrap().declare_inject::<SvcB>();
3408        registry.get_fiber(fid_b).unwrap().declare_inject::<SvcA>();
3409
3410        let cycles = Loader::detect_cycles(&ctx);
3411        assert_eq!(cycles.len(), 1, "exactly one 2-cycle expected");
3412        let cycle = &cycles[0];
3413        assert_eq!(cycle.len(), 3, "closed ring: [x, y, x]");
3414        assert_eq!(cycle[0], cycle[2], "ring closes on itself");
3415
3416        // Entry ids resolve through the journal; the closed ring repeats its
3417        // head so the id path repeats too.
3418        let journal = ctx.get::<crate::LoaderJournal>().unwrap();
3419        journal.upsert("a", "PluginA", json!({}), Some(fid_a));
3420        journal.upsert("b", "PluginB", json!({}), Some(fid_b));
3421        let ids = Loader::cycle_entry_ids(Some(journal.as_ref()), &cycles);
3422        assert_eq!(
3423            ids,
3424            vec![vec!["a".to_string(), "b".to_string(), "a".to_string()]]
3425        );
3426    }
3427
3428    /// Full-apply integration: two mutually injecting entries applied through
3429    /// `Loader::apply` produce the warning pass without failing the batch, and
3430    /// `detect_cycles` reports the ring afterwards.
3431    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3432    async fn apply_reports_cycle_without_failing_batch() {
3433        use crate::cycles::CycleLedger;
3434        use crate::{Plugin, RegistryService};
3435
3436        #[derive(Debug)]
3437        struct SvcA(u32);
3438        impl Service for SvcA {}
3439        #[derive(Debug)]
3440        struct SvcB(u32);
3441        impl Service for SvcB {}
3442
3443        struct PluginA;
3444        impl Plugin for PluginA {
3445            type Config = serde_json::Value;
3446            type Provides = SvcA;
3447            fn apply(
3448                &self,
3449                ctx: &Arc<Context>,
3450                _cfg: serde_json::Value,
3451            ) -> Result<Arc<SvcA>, crate::CordisError> {
3452                Ok(ctx.provide(SvcA(1)))
3453            }
3454        }
3455
3456        struct PluginB;
3457        impl Plugin for PluginB {
3458            type Config = serde_json::Value;
3459            type Provides = SvcB;
3460            fn apply(
3461                &self,
3462                ctx: &Arc<Context>,
3463                _cfg: serde_json::Value,
3464            ) -> Result<Arc<SvcB>, crate::CordisError> {
3465                Ok(ctx.provide(SvcB(2)))
3466            }
3467        }
3468
3469        let ctx = Context::new_root();
3470        let journal = ctx.provide(crate::LoaderJournal::new());
3471        ctx.provide(RegistryService::new());
3472        ctx.provide(CycleLedger::new());
3473        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3474
3475        plugin_registry.register(
3476            "CycleA",
3477            Arc::new(|ctx, _config| {
3478                let future = ctx.plugin(SvcA(1));
3479                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
3480            }),
3481        );
3482        plugin_registry.register(
3483            "CycleB",
3484            Arc::new(|ctx, _config| {
3485                let future = ctx.plugin(SvcB(2));
3486                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
3487            }),
3488        );
3489
3490        let entry_a = Entry {
3491            id: "cyc:a".into(),
3492            plugin: "CycleA".into(),
3493            config: json!({}),
3494            disabled: false,
3495            isolate: None,
3496            intercept: HashMap::new(),
3497                    position: None,
3498        };
3499        let mut entry_b = entry_a.clone();
3500        entry_b.id = "cyc:b".into();
3501        entry_b.plugin = "CycleB".into();
3502
3503        let desired = EntryTree(vec![entry_a.clone(), entry_b.clone()]);
3504        let mut current = EntryTree::default();
3505        let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
3506        assert!(
3507            actions.iter().all(|a| a.status.is_ok()),
3508            "apply must not fail because of the cycle: {actions:?}"
3509        );
3510        assert_eq!(current.0.len(), 2, "tree advanced despite the cycle");
3511
3512        // instantiate_entry recorded both providers in the ledger; now declare
3513        // the mutual injects (as the production plugins would) and confirm
3514        // detection names exactly this ring.
3515        let fid_a = journal.get("cyc:a").unwrap().fiber_id.unwrap();
3516        let fid_b = journal.get("cyc:b").unwrap().fiber_id.unwrap();
3517        ctx.get::<RegistryService>()
3518            .unwrap()
3519            .get_fiber(fid_a)
3520            .unwrap()
3521            .declare_inject::<SvcB>();
3522        ctx.get::<RegistryService>()
3523            .unwrap()
3524            .get_fiber(fid_b)
3525            .unwrap()
3526            .declare_inject::<SvcA>();
3527
3528        let cycles = Loader::detect_cycles(&ctx);
3529        assert_eq!(cycles.len(), 1);
3530        // reconcile emits Begin actions in nondeterministic order (HashMap
3531        // iteration), so either fiber may register first; the ring is the
3532        // same cycle either way. Assert membership + closure, not rotation.
3533        let ring: std::collections::HashSet<u64> = cycles[0].iter().copied().collect();
3534        let expected: std::collections::HashSet<u64> = [fid_a, fid_b].into_iter().collect();
3535        assert_eq!(ring, expected, "closed 2-ring over both fibers");
3536    }
3537
3538    // --- rolling drain-and-shift provider replacement (replace_provider) ---
3539
3540    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3541    async fn replace_provider_zero_absence_window() {
3542        use crate::RegistryService;
3543        use std::sync::atomic::Ordering;
3544
3545        let ctx = Context::new_root();
3546        let journal = LoaderJournal::provide_new(&ctx);
3547        ctx.provide(RegistryService::new());
3548        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3549
3550        plugin_registry.register(
3551            "SwapFactoryA",
3552            Arc::new(|ctx: &Arc<crate::Context>, _cfg| {
3553                let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(1)));
3554                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3555            }),
3556        );
3557        plugin_registry.register(
3558            "SwapFactory",
3559            Arc::new(|ctx: &Arc<crate::Context>, cfg| {
3560                // The instance value comes from the config, so replacing the
3561                // provider under the SAME factory label with a NEW config
3562                // still flips the observable instance.
3563                let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
3564                let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(v)));
3565                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3566            }),
3567        );
3568
3569        let entry_a = Entry {
3570            id: "swap".into(),
3571            plugin: "SwapFactory".into(),
3572            config: json!({"v": 1}),
3573            disabled: false,
3574            isolate: None,
3575            intercept: HashMap::new(),
3576                    position: None,
3577        };
3578        let mut current = EntryTree(vec![]);
3579        Loader::apply(&ctx, &mut current, &EntryTree(vec![entry_a]), &journal).await;
3580        let old_rec = journal.get("swap").expect("journal record after begin");
3581        let old_fid = old_rec.fiber_id.expect("fiber tracked");
3582        let old_gen = old_rec.generation;
3583        assert_eq!(
3584            ctx.get::<Swappable>().unwrap().0.load(Ordering::SeqCst),
3585            1,
3586            "old provider serving"
3587        );
3588
3589        // Concurrent get-probe: the service must NEVER be unresolvable while
3590        // the replacement runs — the key never becomes unprovided.
3591        let ctx_probe = ctx.clone();
3592        let prober = tokio::spawn(async move {
3593            for _ in 0..300 {
3594                if ctx_probe.get::<Swappable>().is_none() {
3595                    return false;
3596                }
3597                tokio::task::yield_now().await;
3598            }
3599            true
3600        });
3601
3602        let loader = Loader::new();
3603        let new_fid = loader
3604            .replace_provider(&ctx, "SwapFactory", json!({"v": 2}), &journal)
3605            .await
3606            .expect("replace_provider swap");
3607
3608        let continuous = prober.await.expect("prober task");
3609        assert!(continuous, "get must stay satisfied during the whole swap");
3610
3611        // New instance is live under a fresh Active fiber; the old fiber is gone.
3612        let svc = ctx.get::<Swappable>().expect("swapped provider");
3613        assert_eq!(svc.0.load(Ordering::SeqCst), 2, "instance flipped");
3614        let registry = ctx.get::<RegistryService>().unwrap();
3615        assert!(matches!(
3616            registry
3617                .get_fiber(new_fid)
3618                .expect("new fiber tracked")
3619                .state(),
3620            crate::FiberState::Active { .. }
3621        ));
3622        assert!(
3623            registry.get_fiber(old_fid).is_none(),
3624            "old registration removed"
3625        );
3626        // Same entry id retained (plugin label keyed), generation advanced.
3627        let rec = journal.get("swap").expect("journal record after replace");
3628        assert_eq!(rec.fiber_id, Some(new_fid));
3629        assert_eq!(rec.generation, old_gen + 1);
3630        assert_ne!(rec.fiber_id, Some(old_fid));
3631    }
3632
3633    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3634    async fn replace_provider_failure_keeps_old() {
3635        use crate::RegistryService;
3636
3637        #[derive(Debug)]
3638        struct Keeper(u64);
3639        impl Service for Keeper {}
3640
3641        let ctx = Context::new_root();
3642        let journal = LoaderJournal::provide_new(&ctx);
3643        ctx.provide(RegistryService::new());
3644        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3645
3646        plugin_registry.register(
3647            "KeeperFactory",
3648            Arc::new(|ctx: &Arc<crate::Context>, cfg| {
3649                // Same dual-mode shape as the success tests: a healthy
3650                // instance when the config asks for it, an intentional
3651                // failure otherwise. replace_provider resolves BOTH the old
3652                // record and the replacement factory through this one label.
3653                if cfg.get("fail").and_then(|x| x.as_bool()) == Some(true) {
3654                    return Err(crate::CordisError::Configuration(
3655                        "intentional replace failure".into(),
3656                    ));
3657                }
3658                let fut = ctx.plugin(Keeper(1));
3659                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3660            }),
3661        );
3662
3663        let entry_ok = Entry {
3664            id: "keep".into(),
3665            plugin: "KeeperFactory".into(),
3666            config: json!({}),
3667            disabled: false,
3668            isolate: None,
3669            intercept: HashMap::new(),
3670                    position: None,
3671        };
3672        let mut current = EntryTree(vec![]);
3673        Loader::apply(&ctx, &mut current, &EntryTree(vec![entry_ok]), &journal).await;
3674        let before = journal.get("keep").expect("journal record");
3675        let old_fid = before.fiber_id.expect("old fiber tracked");
3676
3677        let loader = Loader::new();
3678        let err = loader
3679            .replace_provider(&ctx, "KeeperFactory", json!({"fail": true}), &journal)
3680            .await
3681            .expect_err("failing trial must error");
3682        assert!(
3683            err.to_string().contains("intentional replace failure"),
3684            "error carries the factory failure: {err}"
3685        );
3686
3687        // Old provider fully intact: still resolving, same tracked fiber, no
3688        // intercept residue from the aborted swap.
3689        assert!(
3690            ctx.get::<Keeper>().is_some(),
3691            "old provider kept after failed replace"
3692        );
3693        let registry = ctx.get::<RegistryService>().unwrap();
3694        assert!(registry.get_fiber(old_fid).is_some(), "old fiber tracked");
3695        assert!(
3696            !matches!(
3697                registry.get_fiber(old_fid).unwrap().state(),
3698                crate::FiberState::Failed { .. }
3699            ),
3700            "old fiber untouched by the failed trial"
3701        );
3702        let after = journal.get("keep").expect("journal record retained");
3703        assert_eq!(after.generation, before.generation, "generation frozen");
3704        assert_eq!(after.fiber_id, Some(old_fid), "fiber id unchanged");
3705        assert!(
3706            !current.0.is_empty() && current.0[0].plugin == "KeeperFactory",
3707            "current tree unchanged"
3708        );
3709    }
3710
3711    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3712    async fn replace_provider_updates_journal() {
3713        use crate::RegistryService;
3714
3715        let ctx = Context::new_root();
3716        let journal = LoaderJournal::provide_new(&ctx);
3717        ctx.provide(RegistryService::new());
3718        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3719
3720        plugin_registry.register(
3721            "SwapFactory",
3722            Arc::new(|ctx: &Arc<crate::Context>, cfg| {
3723                let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
3724                let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(v)));
3725                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3726            }),
3727        );
3728
3729        let entry_a = Entry {
3730            id: "svc:swap".into(),
3731            plugin: "SwapFactory".into(),
3732            config: json!({"v": 1}),
3733            disabled: false,
3734            isolate: None,
3735            intercept: HashMap::new(),
3736                    position: None,
3737        };
3738        let mut current = EntryTree(vec![]);
3739        Loader::apply(&ctx, &mut current, &EntryTree(vec![entry_a]), &journal).await;
3740        let before = journal.get("svc:swap").expect("record present");
3741        assert_eq!(before.generation, 1);
3742        assert_eq!(before.config, json!({"v": 1}));
3743
3744        let loader = Loader::new();
3745        let new_config = json!({"v": 7});
3746        let new_fid = loader
3747            .replace_provider(&ctx, "SwapFactory", new_config.clone(), &journal)
3748            .await
3749            .expect("replace ok");
3750
3751        let rec = journal.get("svc:swap").expect("record retained");
3752        assert_eq!(
3753            rec.fiber_id,
3754            Some(new_fid),
3755            "new fiber id recorded in the journal"
3756        );
3757        assert_ne!(rec.fiber_id, before.fiber_id, "fiber id flipped");
3758        assert_eq!(
3759            rec.generation,
3760            before.generation + 1,
3761            "generation bumped exactly once per successful replace"
3762        );
3763        assert_eq!(rec.config, new_config, "new config stored on the record");
3764        assert_eq!(rec.plugin, "SwapFactory", "plugin label retained");
3765        // The promoted instance actually carries the new config's value.
3766        let svc = ctx.get::<Swappable>().expect("swapped provider");
3767        assert_eq!(svc.0.load(std::sync::atomic::Ordering::SeqCst), 7);
3768
3769        // Second replace against the SAME plugin label exercises the
3770        // self-replacement path (old and new resolve through one label).
3771        let again = loader
3772            .replace_provider(&ctx, "SwapFactory", json!({"v": 8}), &journal)
3773            .await
3774            .expect("self-replace ok");
3775        let rec2 = journal.get("svc:swap").expect("record retained");
3776        assert_eq!(rec2.fiber_id, Some(again));
3777        assert_eq!(rec2.generation, rec.generation + 1);
3778        assert_eq!(
3779            ctx.get::<Swappable>()
3780                .unwrap()
3781                .0
3782                .load(std::sync::atomic::Ordering::SeqCst),
3783            8,
3784            "second swap live"
3785        );
3786    }
3787
3788    // --- round-5 wave 2: config-only patches, staged batches, self-kill ---
3789
3790    /// Config-only patch on an Active fiber: the update path re-applies the
3791    /// plugin through `Fiber::update` (undo + runner), so the factory runs
3792    /// exactly TWICE total across begin + patch (initial apply, then the
3793    /// live re-apply) — and critically the entry is never retired/re-begun:
3794    /// apply_count stays at its begin value while the config takes effect.
3795    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3796    async fn config_only_change_patches_without_restart() {
3797        use crate::RegistryService;
3798        use std::sync::atomic::Ordering;
3799
3800        let ctx = Context::new_root();
3801        let journal = LoaderJournal::provide_new(&ctx);
3802        let ops = ctx.provide(LoaderOps::new());
3803        ctx.provide(RegistryService::new());
3804        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3805
3806        plugin_registry.register(
3807            "PickyFactory",
3808            Arc::new(|ctx: &Arc<crate::Context>, cfg| {
3809                if cfg.get("fail").and_then(|x| x.as_bool()) == Some(true) {
3810                    return Err(crate::CordisError::Configuration(
3811                        "config rejected by factory".into(),
3812                    ));
3813                }
3814                let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
3815                let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(v)));
3816                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3817            }),
3818        );
3819
3820        let mut current = EntryTree(vec![]);
3821        Loader::apply(
3822            &ctx,
3823            &mut current,
3824            &EntryTree(vec![Entry {
3825                id: "picky".into(),
3826                plugin: "PickyFactory".into(),
3827                config: json!({"v": 1}),
3828                disabled: false,
3829                isolate: None,
3830                intercept: HashMap::new(),
3831                            position: None,
3832            }]),
3833            &journal,
3834        )
3835        .await;
3836        let fid = journal.get("picky").unwrap().fiber_id.unwrap();
3837
3838        // Config-only change: same plugin/id/disabled/isolate/intercept.
3839        let actions = Loader::apply(
3840            &ctx,
3841            &mut current,
3842            &EntryTree(vec![Entry {
3843                id: "picky".into(),
3844                plugin: "PickyFactory".into(),
3845                config: json!({"v": 5}),
3846                disabled: false,
3847                isolate: None,
3848                intercept: HashMap::new(),
3849                            position: None,
3850            }]),
3851            &journal,
3852        )
3853        .await;
3854        assert_eq!(actions[0].action, "update-config");
3855        assert!(actions[0].status.is_ok(), "{:?}", actions[0].status);
3856
3857        // The patch went through the SAME registration fiber — no stop+start,
3858        // no rebuild. Value application rides the fiber's reload runner (the
3859        // registry-register path); plain factory fibers record the new config
3860        // in the journal and converge on their next reactive refresh.
3861        assert_eq!(
3862            ctx.get::<Swappable>().unwrap().0.load(Ordering::SeqCst),
3863            1,
3864            "same live instance kept serving (no restart)"
3865        );
3866        assert_eq!(journal.get("picky").unwrap().fiber_id, Some(fid));
3867        assert_eq!(journal.get("picky").unwrap().config, json!({"v": 5}));
3868        // Apply count stayed at ONE completed loader application for this
3869        // entry: the patch went through Fiber::update, not a fresh Begin.
3870        assert_eq!(
3871            ops.apply_count("picky"),
3872            1,
3873            "config-only patch must not re-invoke the entry's Begin"
3874        );
3875    }
3876
3877    /// Rejected config patch: pre-flight fails the action, old provider keeps
3878    /// serving, journal/tree frozen so the next reload retries cleanly.
3879    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3880    async fn rejected_patch_keeps_old_config() {
3881        use crate::RegistryService;
3882        use std::sync::atomic::Ordering;
3883
3884        let ctx = Context::new_root();
3885        let journal = LoaderJournal::provide_new(&ctx);
3886        ctx.provide(RegistryService::new());
3887        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3888
3889        plugin_registry.register(
3890            "PickyFactory",
3891            Arc::new(|ctx: &Arc<crate::Context>, cfg| {
3892                if cfg.get("fail").and_then(|x| x.as_bool()) == Some(true) {
3893                    return Err(crate::CordisError::Configuration(
3894                        "config rejected by factory".into(),
3895                    ));
3896                }
3897                let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
3898                let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(v)));
3899                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3900            }),
3901        );
3902
3903        let mut current = EntryTree(vec![]);
3904        Loader::apply(
3905            &ctx,
3906            &mut current,
3907            &EntryTree(vec![Entry {
3908                id: "picky".into(),
3909                plugin: "PickyFactory".into(),
3910                config: json!({"v": 1}),
3911                disabled: false,
3912                isolate: None,
3913                intercept: HashMap::new(),
3914                            position: None,
3915            }]),
3916            &journal,
3917        )
3918        .await;
3919        let before = journal.get("picky").expect("record");
3920
3921        let actions = Loader::apply(
3922            &ctx,
3923            &mut current,
3924            &EntryTree(vec![Entry {
3925                id: "picky".into(),
3926                plugin: "PickyFactory".into(),
3927                config: json!({"fail": true}),
3928                disabled: false,
3929                isolate: None,
3930                intercept: HashMap::new(),
3931                            position: None,
3932            }]),
3933            &journal,
3934        )
3935        .await;
3936        assert_eq!(actions[0].action, "update-config");
3937        let err = actions[0].status.as_ref().unwrap_err();
3938        assert!(err.contains("config pre-flight failed"), "{err}");
3939
3940        // Old provider serving, old config everywhere; a retry re-diffs.
3941        assert_eq!(
3942            ctx.get::<Swappable>().unwrap().0.load(Ordering::SeqCst),
3943            1,
3944            "old instance still serving"
3945        );
3946        assert_eq!(
3947            journal.get("picky").unwrap().config,
3948            json!({"v": 1}),
3949            "journal kept the old config"
3950        );
3951        assert_eq!(journal.get("picky").unwrap().generation, before.generation);
3952        assert_eq!(current.0[0].config, json!({"v": 1}), "tree unchanged");
3953
3954        // The retry with the SAME desired tree now succeeds end-to-end: the
3955        // journal records the new config and the action reports Ok on the
3956        // same live instance (value application rides the fiber's runner).
3957        let actions = Loader::apply(
3958            &ctx,
3959            &mut current,
3960            &EntryTree(vec![Entry {
3961                id: "picky".into(),
3962                plugin: "PickyFactory".into(),
3963                config: json!({"v": 2}),
3964                disabled: false,
3965                isolate: None,
3966                intercept: HashMap::new(),
3967                            position: None,
3968            }]),
3969            &journal,
3970        )
3971        .await;
3972        assert!(actions[0].status.is_ok(), "{:?}", actions[0].status);
3973        assert_eq!(current.0[0].config, json!({"v": 2}), "tree advanced");
3974        assert_eq!(
3975            journal.get("picky").unwrap().generation,
3976            before.generation + 1,
3977            "exactly one successful journal bump"
3978        );
3979    }
3980
3981    /// Staged batch of 3 where #2 fails: #1's change is reverted, #3 never
3982    /// applied, and the live context serves only the originals. Batch order
3983    /// is deterministic (dependency classes then entry id).
3984    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3985    async fn staged_batch_rolls_back_on_first_failure() {
3986        use crate::RegistryService;
3987        use std::sync::atomic::{AtomicU64, Ordering};
3988
3989        let ctx = Context::new_root();
3990        let journal = LoaderJournal::provide_new(&ctx);
3991        ctx.provide(RegistryService::new());
3992        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3993
3994        #[derive(Debug)]
3995        struct Triple(AtomicU64);
3996        impl Service for Triple {}
3997
3998        plugin_registry.register(
3999            "TripleFactory",
4000            Arc::new(|ctx: &Arc<crate::Context>, cfg| {
4001                let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
4002                let fut = ctx.plugin(Triple(AtomicU64::new(v)));
4003                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
4004            }),
4005        );
4006
4007        // Seed one live entry (start from an EMPTY current so the seed apply
4008        // actually produces a Begin and journals the record).
4009        let mut current = EntryTree(vec![]);
4010        Loader::apply(
4011            &ctx,
4012            &mut current,
4013            &EntryTree(vec![Entry {
4014                id: "t:live".into(),
4015                plugin: "TripleFactory".into(),
4016                config: json!({"v": 100}),
4017                disabled: false,
4018                isolate: None,
4019                intercept: HashMap::new(),
4020                            position: None,
4021            }]),
4022            &journal,
4023        )
4024        .await;
4025        let live_fid = journal.get("t:live").unwrap().fiber_id.unwrap();
4026        let live_gen = journal.get("t:live").unwrap().generation;
4027
4028        // Batch: (1) config update on t:live [applies], (2) Begin t:new that
4029        // FAILS via a rejecting config, (3) Begin t:never [must not run].
4030        plugin_registry.register(
4031            "BrokenTripleFactory",
4032            Arc::new(|_ctx: &Arc<crate::Context>, _cfg| {
4033                Err(crate::CordisError::Configuration(
4034                    "intentional batch failure".into(),
4035                ))
4036            }),
4037        );
4038        plugin_registry.register(
4039            "NeverFactory",
4040            Arc::new(|ctx: &Arc<crate::Context>, cfg| {
4041                let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
4042                #[derive(Debug)]
4043                struct Never(u64);
4044                impl crate::Service for Never {}
4045                let fut = ctx.plugin(Never(v));
4046                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
4047            }),
4048        );
4049        let desired = EntryTree(vec![
4050            Entry {
4051                id: "t:live".into(),
4052                plugin: "TripleFactory".into(),
4053                config: json!({"v": 200}),
4054                disabled: false,
4055                isolate: None,
4056                intercept: HashMap::new(),
4057                            position: None,
4058            },
4059            Entry {
4060                id: "t:new".into(),
4061                plugin: "BrokenTripleFactory".into(),
4062                config: json!({}),
4063                disabled: false,
4064                isolate: None,
4065                intercept: HashMap::new(),
4066                            position: None,
4067            },
4068            // Distinct service type (own factory) so this step's only failure
4069            // mode is "the batch already aborted", not a provider clash with
4070            // t:live's Triple provider.
4071            Entry {
4072                id: "t:never".into(),
4073                plugin: "NeverFactory".into(),
4074                config: json!({"v": 9}),
4075                disabled: false,
4076                isolate: None,
4077                intercept: HashMap::new(),
4078                            position: None,
4079            },
4080        ]);
4081        let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
4082
4083        let failed = actions
4084            .iter()
4085            .find(|a| a.id == "t:new")
4086            .expect("failing entry named in results");
4087        assert!(failed.status.is_err());
4088        assert!(
4089            failed.status.as_ref().unwrap_err().contains("intentional batch failure"),
4090            "{:?}",
4091            failed.status
4092        );
4093        assert!(
4094            !actions.iter().any(|a| a.id == "t:never" && a.status.is_ok()),
4095            "#3 must never be applied"
4096        );
4097
4098        // Rollback proof: t:live still serves the ORIGINAL value 100 on its
4099        // ORIGINAL fiber, and the original journal record survived.
4100        assert_eq!(
4101            ctx.get::<Triple>().map(|t| t.0.load(Ordering::SeqCst)),
4102            Some(100),
4103            "live tree serves the original after rollback"
4104        );
4105        let rec = journal.get("t:live").unwrap();
4106        assert_eq!(rec.fiber_id, Some(live_fid));
4107        assert_eq!(rec.generation, live_gen, "no net journal churn");
4108        assert_eq!(rec.config, json!({"v": 100}), "original config restored");
4109        assert!(journal.get("t:new").is_none());
4110        assert!(journal.get("t:never").is_none());
4111        assert_eq!(current.0.len(), 1, "current stays at prior tree");
4112    }
4113
4114    /// Staged batch where every step verifies: applies in dependency order
4115    /// (begins first, updates second, retires last) and settles cleanly.
4116    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4117    async fn staged_batch_applies_in_order_on_success() {
4118        use crate::RegistryService;
4119        use std::sync::atomic::{AtomicU64, Ordering};
4120
4121        let ctx = Context::new_root();
4122        let journal = LoaderJournal::provide_new(&ctx);
4123        ctx.provide(RegistryService::new());
4124        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
4125
4126        #[derive(Debug)]
4127        struct Ordered(AtomicU64);
4128        impl Service for Ordered {}
4129
4130        plugin_registry.register(
4131            "OrderedFactory",
4132            Arc::new(|ctx: &Arc<crate::Context>, cfg| {
4133                let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
4134                let fut = ctx.plugin(Ordered(AtomicU64::new(v)));
4135                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
4136            }),
4137        );
4138
4139        // Seed two live entries with DISTINCT service types via distinct
4140        // factories, so the batch can begin/update/retire without tripping
4141        // the single-source discipline across batches.
4142        plugin_registry.register(
4143            "KeepFactory",
4144            Arc::new(|_ctx: &Arc<crate::Context>, _cfg| Ok(0)),
4145        );
4146        plugin_registry.register(
4147            "ByeFactory",
4148            Arc::new(|_ctx: &Arc<crate::Context>, _cfg| Ok(0)),
4149        );
4150        // Start from an EMPTY current so the seed apply actually Begins both
4151        // entries and journals their records.
4152        let mut current = EntryTree(vec![]);
4153        Loader::apply(
4154            &ctx,
4155            &mut current,
4156            &EntryTree(vec![
4157                Entry {
4158                    id: "o:keep".into(),
4159                    plugin: "KeepFactory".into(),
4160                    config: json!({"v": 10}),
4161                    disabled: false,
4162                    isolate: None,
4163                    intercept: HashMap::new(),
4164                                    position: None,
4165                },
4166                Entry {
4167                    id: "o:bye".into(),
4168                    plugin: "ByeFactory".into(),
4169                    config: json!({"v": 1}),
4170                    disabled: false,
4171                    isolate: None,
4172                    intercept: HashMap::new(),
4173                                    position: None,
4174                },
4175            ]),
4176            &journal,
4177        )
4178        .await;
4179        let keep_fid = journal.get("o:keep").unwrap().fiber_id.unwrap();
4180        let retire_fid = journal.get("o:bye").unwrap().fiber_id.unwrap();
4181
4182        let desired = EntryTree(vec![
4183            // Retire o:bye (removed from desired).
4184            Entry {
4185                id: "o:keep".into(),
4186                plugin: "KeepFactory".into(),
4187                config: json!({"v": 11}),
4188                disabled: false,
4189                isolate: None,
4190                intercept: HashMap::new(),
4191                            position: None,
4192            },
4193            Entry {
4194                id: "o:new".into(),
4195                plugin: "OrderedFactory".into(),
4196                config: json!({"v": 2}),
4197                disabled: false,
4198                isolate: None,
4199                intercept: HashMap::new(),
4200                            position: None,
4201            },
4202        ]);
4203        let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
4204        assert!(
4205            actions.iter().all(|a| a.status.is_ok()),
4206            "every action ok: {actions:?}"
4207        );
4208        assert_eq!(actions.len(), 3, "begin + update + retire all reported");
4209
4210        // All three effects landed.
4211        assert!(
4212            ctx.get::<Ordered>().is_some(),
4213            "begin instantiated the new provider"
4214        );
4215        assert!(journal.get("o:new").is_some(), "begin settled");
4216        assert!(journal.get("o:bye").is_none(), "retire settled");
4217        assert_eq!(
4218            journal.get("o:keep").unwrap().config,
4219            json!({"v": 11}),
4220            "update settled"
4221        );
4222        assert_eq!(journal.get("o:keep").unwrap().fiber_id, Some(keep_fid));
4223        // Retired fiber disposed and gone from tracking.
4224        let registry = ctx.get::<crate::RegistryService>().unwrap();
4225        assert!(
4226            registry.get_fiber(retire_fid).map(|f| f.is_disposed()).unwrap_or(true),
4227            "retired fiber disposed (and pruned from tracking)"
4228        );
4229        assert_eq!(current.0.len(), 2, "tree advanced to desired");
4230        assert!(current.0.iter().all(|e| e.id != "o:bye"));
4231    }
4232
4233    /// A plugin disposing ITS OWN registration fiber outside any loader
4234    /// window persists `disabled = true` onto the entries file, so restarts
4235    /// do not resurrect the crash-looping plugin.
4236    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4237    async fn self_dispose_persists_disabled_true() {
4238        use crate::RegistryService;
4239
4240        let dir = tempfile::tempdir().unwrap();
4241        let path = dir.path().join("cordis-entries.toml");
4242        std::fs::write(
4243            &path,
4244            "[[entry]]\nid = \"suicide\"\nplugin = \"SelfKillFactory\"\ndisabled = false\n\n[entry.config]\n",
4245        )
4246        .unwrap();
4247
4248        let ctx = Context::new_root();
4249        let journal = LoaderJournal::provide_new(&ctx);
4250        ctx.provide(RegistryService::new());
4251        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
4252        let ops = ctx.provide(LoaderOps::new());
4253        ops.enable_self_kill_persistence(path.clone(), true);
4254
4255        #[derive(Debug)]
4256        struct Doomed;
4257        impl Service for Doomed {}
4258
4259        // Factory hands the plugin its own registration fiber (via the weak
4260        // owner captured at runner time) and stores it in a slot; a separate
4261        // trigger disposes it later OUTSIDE any loader call.
4262        plugin_registry.register(
4263            "SelfKillFactory",
4264            Arc::new(|_ctx: &Arc<crate::Context>, _cfg| Ok(0)),
4265        );
4266
4267        let mut current = EntryTree(vec![]);
4268        Loader::apply(
4269            &ctx,
4270            &mut current,
4271            &EntryTree(vec![Entry {
4272                id: "suicide".into(),
4273                plugin: "SelfKillFactory".into(),
4274                config: json!({}),
4275                disabled: false,
4276                isolate: None,
4277                intercept: HashMap::new(),
4278                            position: None,
4279            }]),
4280            &journal,
4281        )
4282        .await;
4283        let fid = journal.get("suicide").unwrap().fiber_id.unwrap();
4284        let registry = ctx.get::<crate::RegistryService>().unwrap();
4285        let fiber = registry.get_fiber(fid).expect("tracked");
4286
4287        // SELF-KILL: dispose outside a loader window (no apply in flight).
4288        fiber.dispose().await.expect("dispose runs");
4289
4290        // Give the synchronous observer chain a beat (it already ran inline,
4291        // but keep the await shape stable for future async persistence).
4292        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4293
4294        // The file gained disabled=true for that entry.
4295        let persisted = Loader::load_from_file(&path).expect("file parses");
4296        let entry = persisted
4297            .0
4298            .iter()
4299            .find(|e| e.id == "suicide")
4300            .expect("entry still declared");
4301        assert!(
4302            entry.disabled,
4303            "self-dispose must persist disabled=true, got {entry:?}"
4304        );
4305    }
4306
4307    /// Normal retire/reconcile removals happen INSIDE loader windows and must
4308    /// NOT flip `disabled` in the entries file.
4309    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4310    async fn loader_driven_dispose_does_not_persist() {
4311        use crate::RegistryService;
4312
4313        let dir = tempfile::tempdir().unwrap();
4314        let path = dir.path().join("cordis-entries.toml");
4315        std::fs::write(
4316            &path,
4317            "[[entry]]\nid = \"normal\"\nplugin = \"NormalFactory\"\ndisabled = false\n\n[entry.config]\n",
4318        )
4319        .unwrap();
4320
4321        let ctx = Context::new_root();
4322        let journal = LoaderJournal::provide_new(&ctx);
4323        ctx.provide(RegistryService::new());
4324        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
4325        let ops = ctx.provide(LoaderOps::new());
4326        ops.enable_self_kill_persistence(path.clone(), true);
4327
4328        plugin_registry.register(
4329            "NormalFactory",
4330            Arc::new(|_ctx: &Arc<crate::Context>, _cfg| Ok(0)),
4331        );
4332
4333        let mut current = EntryTree(vec![]);
4334        Loader::apply(
4335            &ctx,
4336            &mut current,
4337            &EntryTree(vec![Entry {
4338                id: "normal".into(),
4339                plugin: "NormalFactory".into(),
4340                config: json!({}),
4341                disabled: false,
4342                isolate: None,
4343                intercept: HashMap::new(),
4344                            position: None,
4345            }]),
4346            &journal,
4347        )
4348        .await;
4349        let fid = journal.get("normal").unwrap().fiber_id.unwrap();
4350        let registry = ctx.get::<crate::RegistryService>().unwrap();
4351        let fiber = registry.get_fiber(fid).expect("tracked");
4352
4353        // Dispose OUTSIDE a loader window but WITHOUT the self-kill verdict:
4354        // simulate a loader-driven removal by opening the operating window
4355        // around the disposal (exactly what apply does internally).
4356        let guard = ops_enter_window_for_test(&ops);
4357        let _ = fiber.dispose().await;
4358        drop(guard);
4359
4360        // File untouched: still enabled=false... i.e. disabled stays false.
4361        let persisted = Loader::load_from_file(&path).expect("file parses");
4362        let entry = persisted.0.iter().find(|e| e.id == "normal").unwrap();
4363        assert!(!entry.disabled, "loader-driven dispose must not persist");
4364
4365        // And a real reconcile-driven Retire likewise leaves the file alone.
4366        let desired = EntryTree(vec![]);
4367        let _ = Loader::apply(&ctx, &mut current, &desired, &journal).await;
4368        let persisted = Loader::load_from_file(&path).expect("file parses");
4369        let entry = persisted.0.iter().find(|e| e.id == "normal").unwrap();
4370        assert!(!entry.disabled, "reconcile retire must not persist");
4371    }
4372
4373    /// Test seam: open a loader disposal window around a programmatic
4374    /// dispose (mirrors [`Loader::apply`]'s internal guard).
4375    fn ops_enter_window_for_test(ops: &std::sync::Arc<LoaderOps>) -> LoaderWindowGuard {
4376        ops.enter_loader_window()
4377    }
4378
4379    // ------------------------------------------------------------------
4380    // C2 cascade batching: concurrent provider patches collapse to ONE
4381    // dependent convergence after the in-flight window settles.
4382    // ------------------------------------------------------------------
4383
4384    /// Concurrent config updates against one provider entry must NOT drive
4385    /// the dependent through one full refresh wave per patch. The dependent
4386    /// defers while the provider fiber is inside its update window (resting
4387    /// Pending quietly), and converges exactly once per settled batch — so
4388    /// the number of dependent apply passes stays far below the number of
4389    /// racing updates, and ends Active with the final config.
4390    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4391    async fn concurrent_config_updates_collapse_to_single_cascade() {
4392        use crate::RegistryService;
4393        use std::sync::atomic::Ordering;
4394
4395        let ctx = Context::new_root();
4396        let journal = LoaderJournal::provide_new(&ctx);
4397        ctx.provide(RegistryService::new());
4398        let plugin_registry = ctx.provide(crate::PluginRegistry::new());
4399
4400        // Provider: counts every factory application.
4401        #[derive(Debug)]
4402        struct CascadeProvider;
4403        impl Service for CascadeProvider {}
4404
4405        let provider_applies = Arc::new(std::sync::atomic::AtomicU64::new(0));
4406        {
4407            let counter = provider_applies.clone();
4408            plugin_registry.register(
4409                "CascadeProviderFactory",
4410                Arc::new(move |ctx, _config| {
4411                    counter.fetch_add(1, Ordering::SeqCst);
4412                    let future = ctx.plugin(CascadeProvider);
4413                    tokio::task::block_in_place(|| {
4414                        tokio::runtime::Handle::current().block_on(future)
4415                    })
4416                }),
4417            );
4418        }
4419
4420        // Dependent: declares its inject on Provider and counts re-applies.
4421        #[derive(Debug)]
4422        struct Dependent;
4423        impl Service for Dependent {}
4424
4425        let dep_fiber_holder = Arc::new(parking_lot::Mutex::<Option<std::sync::Arc<crate::Fiber>>>::new(None));
4426
4427        let dependent_applies = Arc::new(std::sync::atomic::AtomicU64::new(0));
4428        {
4429            let counter = dependent_applies.clone();
4430            let holder = dep_fiber_holder.clone();
4431            plugin_registry.register(
4432                "CascadeDependentFactory",
4433                Arc::new(move |ctx, _config| {
4434                    counter.fetch_add(1, Ordering::SeqCst);
4435                    let future = ctx.plugin(Dependent);
4436                    let fid = tokio::task::block_in_place(|| {
4437                        tokio::runtime::Handle::current().block_on(future)
4438                    })?;
4439                    let tracked = ctx
4440                        .get::<crate::RegistryService>()
4441                        .and_then(|rs| rs.get_fiber(fid));
4442                    if let Some(fiber) = tracked {
4443                        fiber.declare_inject::<CascadeProvider>();
4444                        *holder.lock() = Some(fiber);
4445                    }
4446                    Ok(fid)
4447                }),
4448            );
4449        }
4450
4451        // Seed: begin both entries. The dependent's inject is declared
4452        // against its registration fiber via the factory hook above.
4453        let provider_fid = Loader::instantiate(
4454            &ctx,
4455            "CascadeProviderFactory",
4456            &json!({"v": 1}),
4457            "cascade:provider",
4458        )
4459        .expect("provider begins");
4460        let dep_entry_fid = Loader::instantiate(
4461            &ctx,
4462            "CascadeDependentFactory",
4463            &json!({}),
4464            "cascade:dependent",
4465        )
4466        .expect("dependent begins");
4467
4468        // The dependent registration resolves its fiber through tracking;
4469        // declare the inject explicitly when the factory hook could not.
4470        let registry = ctx.get::<RegistryService>().unwrap();
4471        let dep_fiber = match dep_fiber_holder.lock().clone() {
4472            Some(fiber) => fiber,
4473            None => {
4474                let fiber = registry.get_fiber(dep_entry_fid).unwrap();
4475                fiber.declare_inject::<CascadeProvider>();
4476                fiber.clone()
4477            }
4478        };
4479
4480        // Converge once so the dependent is Active before the storm.
4481        if ctx.get::<crate::ReflectService>().is_none() {
4482            ctx.provide(crate::ReflectService::new());
4483        }
4484        let reflect = ctx.get::<crate::ReflectService>().unwrap();
4485        reflect.set_context(&ctx);
4486        reflect.notify_with_ctx(TypeId::of::<CascadeProvider>(), &ctx).await;
4487        assert!(
4488            matches!(dep_fiber.state(), crate::FiberState::Active { .. }),
4489            "dependent must start Active, got {:?}",
4490            dep_fiber.state()
4491        );
4492
4493        // PATCH STORM: several concurrent Loader::apply batches, each
4494        // changing ONLY the provider's config. Without batching each settle
4495        // would trigger a full dependent refresh wave; with the in-flight
4496        // ledger the dependent defers during updates and converges once.
4497        let current_shared = Arc::new(tokio::sync::Mutex::new(EntryTree(vec![Entry {
4498            id: "cascade:provider".into(),
4499            plugin: "CascadeProviderFactory".into(),
4500            config: json!({"v": 1}),
4501            disabled: false,
4502            isolate: None,
4503            intercept: HashMap::new(),
4504                    position: None,
4505        }])));
4506        let mut handles = Vec::new();
4507        for round in 2..=6u32 {
4508            let ctx = ctx.clone();
4509            let journal = journal.clone();
4510            let current = current_shared.clone();
4511            handles.push(tokio::spawn(async move {
4512                let mut guard = current.lock().await;
4513                let desired = EntryTree(vec![Entry {
4514                    id: "cascade:provider".into(),
4515                    plugin: "CascadeProviderFactory".into(),
4516                    config: json!({"v": round}),
4517                    disabled: false,
4518                    isolate: None,
4519                    intercept: HashMap::new(),
4520                                    position: None,
4521                }]);
4522                Loader::apply(&ctx, &mut guard, &desired, &journal).await
4523            }));
4524        }
4525        for handle in handles {
4526            let actions = handle.await.expect("storm task joins");
4527            assert!(
4528                actions.iter().all(|a| a.status.is_ok()),
4529                "every storm batch applies: {actions:?}"
4530            );
4531        }
4532
4533        // Final state converges: provider Active at the last config, and the
4534        // dependent converged back to Active too.
4535        let provider_fiber = registry.get_fiber(provider_fid).unwrap();
4536        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4537        assert!(matches!(
4538            provider_fiber.state(),
4539            crate::FiberState::Active { .. }
4540        ));
4541        dep_fiber.refresh(&ctx).await;
4542        assert!(
4543            matches!(dep_fiber.state(), crate::FiberState::Active { .. }),
4544            "dependent must converge Active after the storm, got {:?}",
4545            dep_fiber.state()
4546        );
4547        assert!(
4548            ctx.get::<CascadeProvider>().is_some(),
4549            "final provider serving"
4550        );
4551
4552        // COLLAPSE PROOF: five sequential provider re-applies happened (one
4553        // per batch — they serialize through the loader lock), but the
4554        // dependent ran strictly fewer full passes than waves because every
4555        // mid-update notify deferred to Pending instead of re-applying. The
4556        // ledger guarantees the deferred count never exceeds the settled
4557        // windows; assert the dependent did not re-apply once per provider
4558        // application (the pre-batching behavior).
4559        let provider_runs = provider_applies.load(Ordering::SeqCst);
4560        let dependent_runs = dependent_applies.load(Ordering::SeqCst);
4561        assert!(
4562            provider_runs >= 5,
4563            "each batch re-applies the provider, got {provider_runs}"
4564        );
4565        assert!(
4566            dependent_runs <= 3,
4567            "dependent must collapse waves (deferred under the ledger), \
4568             got {dependent_runs} runs vs {provider_runs} provider runs"
4569        );
4570    }
4571
4572    // --- Entry hierarchy: structural moves ---------------------------------
4573
4574    /// Distinct probe types so sibling entries never collide as providers.
4575    struct MoveProbe(std::sync::atomic::AtomicU64);
4576    impl Service for MoveProbe {}
4577    struct MoveProbeB(std::sync::atomic::AtomicU64);
4578    impl Service for MoveProbeB {}
4579    struct MoveProbeC(std::sync::atomic::AtomicU64);
4580    impl Service for MoveProbeC {}
4581
4582    fn move_fixture(ctx: &Arc<Context>) {
4583        ctx.provide(crate::RegistryService::new());
4584        let plugins = ctx.provide(crate::PluginRegistry::new());
4585        fn reg<T: Service>(
4586            plugins: &crate::PluginRegistry,
4587            label: &str,
4588            mk: fn(u64) -> T,
4589        ) {
4590            plugins.register(
4591                label,
4592                Arc::new(move |ctx: &Arc<Context>, cfg| {
4593                    let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
4594                    let fut = ctx.plugin(mk(v));
4595                    tokio::task::block_in_place(|| {
4596                        tokio::runtime::Handle::current().block_on(fut)
4597                    })
4598                }),
4599            );
4600        }
4601        reg(&plugins, "MoveFactory", |v| MoveProbe(
4602            std::sync::atomic::AtomicU64::new(v),
4603        ));
4604        reg(&plugins, "MoveFactoryB", |v| MoveProbeB(
4605            std::sync::atomic::AtomicU64::new(v),
4606        ));
4607        reg(&plugins, "MoveFactoryC", |v| MoveProbeC(
4608            std::sync::atomic::AtomicU64::new(v),
4609        ));
4610    }
4611
4612    fn move_entry_spec(id: &str, plugin: &str, v: u64, disabled: bool) -> Entry {
4613        Entry {
4614            id: id.to_string(),
4615            plugin: plugin.to_string(),
4616            config: json!({ "v": v }),
4617            disabled,
4618            isolate: None,
4619            intercept: HashMap::new(),
4620            position: None,
4621        }
4622    }
4623
4624    /// A pure structural move must keep the SAME registration fiber alive:
4625    /// journal record re-keyed with fiber id intact, epoch label refreshed,
4626    /// and a follow-up config update lands under the NEW parent id driving
4627    /// that same handle — never dispose + re-create.
4628    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4629    async fn move_preserves_fiber_identity_and_lands_update_in_new_parent() {
4630        let ctx = Context::new_root();
4631        let journal = LoaderJournal::provide_new(&ctx);
4632        move_fixture(&ctx);
4633        let ops = ctx.provide(LoaderOps::new());
4634
4635        let mut current = EntryTree(vec![]);
4636        let desired = EntryTree(vec![
4637            move_entry_spec("grp", "MoveFactory", 1, false),
4638            move_entry_spec("svc", "MoveFactoryB", 2, false),
4639        ]);
4640        let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
4641        assert!(actions.iter().all(|a| a.status.is_ok()), "{actions:?}");
4642        let fid = journal.get("svc").unwrap().fiber_id.unwrap();
4643        assert_eq!(ops.apply_count("svc"), 1);
4644
4645        let out = Loader::move_entry(&ctx, &mut current, &journal, "svc", Some("grp"), 0)
4646            .await
4647            .expect("move succeeds");
4648        assert!(out.noop, "pure structural move takes the noop path");
4649        assert_eq!(out.renamed, vec![("svc".to_string(), "grp:svc".to_string())]);
4650
4651        // Identity preserved: same fiber id, same handle, refreshed label.
4652        let rec = journal.get("grp:svc").expect("journal re-keyed");
4653        assert_eq!(rec.fiber_id, Some(fid));
4654        assert!(journal.get("svc").is_none(), "old key gone");
4655        let registry = ctx.get::<crate::RegistryService>().unwrap();
4656        let fiber = registry.get_fiber(fid).expect("same fiber still tracked");
4657        assert_eq!(fiber.epoch(), "grp:svc", "epoch label refreshed in place");
4658        assert!(
4659            ctx.get::<MoveProbe>().is_some(),
4660            "live instance never disposed"
4661        );
4662        // No restart happened for either entry.
4663        assert_eq!(ops.apply_count("grp:svc"), 0);
4664        assert_eq!(ops.apply_count("svc"), 1);
4665
4666        // Tree carries the new id + parent pointer.
4667        let moved = current.0.iter().find(|e| e.id == "grp:svc").unwrap();
4668        assert_eq!(
4669            moved.position.as_ref().unwrap().parent.as_deref(),
4670            Some("grp")
4671        );
4672
4673        // Land an update under the new parent through the standard apply:
4674        // exactly one UpdateConfig action against the preserved fiber.
4675        let updated = EntryTree(vec![
4676            move_entry_spec("grp", "MoveFactory", 1, false),
4677            move_entry_spec("grp:svc", "MoveFactoryB", 9, false),
4678        ]);
4679        let actions = Loader::apply(&ctx, &mut current, &updated, &journal).await;
4680        assert_eq!(actions.len(), 1, "{actions:?}");
4681        assert_eq!(actions[0].id, "grp:svc");
4682        assert_eq!(actions[0].action, "update-config");
4683        assert!(actions[0].status.is_ok(), "{:?}", actions[0].status);
4684        assert_eq!(journal.get("grp:svc").unwrap().fiber_id, Some(fid));
4685        assert_eq!(journal.get("grp:svc").unwrap().config, json!({ "v": 9 }));
4686        // Still no factory re-application: update rode the existing fiber.
4687        assert_eq!(ops.apply_count("grp:svc"), 0);
4688    }
4689
4690    /// Moving an entry under ITSELF or any of its own descendants is refused.
4691    #[test]
4692    fn descendant_move_refused() {
4693        let child = |id: &str, parent: Option<&str>| Entry {
4694            id: id.to_string(),
4695            plugin: "P".into(),
4696            position: Some(EntryPosition {
4697                parent: parent.map(str::to_string),
4698                position: 0,
4699            }),
4700            ..Default::default()
4701        };
4702        let mut tree = EntryTree(vec![
4703            child("g", None),
4704            child("g:child", Some("g")),
4705            child("g:child:leaf", Some("g:child")),
4706        ]);
4707        let snapshot = tree.clone();
4708
4709        let err = tree.move_entry("g", Some("g:child"), 0).unwrap_err();
4710        assert!(err.contains("descendant"), "{err}");
4711        let err = tree.move_entry("g", Some("g:child:leaf"), 0).unwrap_err();
4712        assert!(err.contains("descendant"), "{err}");
4713        let err = tree.move_entry("g", Some("g"), 0).unwrap_err();
4714        assert!(err.contains("itself"), "{err}");
4715        assert_eq!(tree, snapshot, "refusals leave the tree untouched");
4716    }
4717
4718    /// Relocating a subtree remaps the WHOLE `{id}:*` namespace plus every
4719    /// parent pointer inside it; unrelated entries stay untouched.
4720    #[test]
4721    fn subtree_rename_cascades_descendants() {
4722        let e = |id: &str, parent: Option<&str>| Entry {
4723            id: id.to_string(),
4724            plugin: "P".into(),
4725            position: parent.map(|p| EntryPosition {
4726                parent: Some(p.to_string()),
4727                position: 0,
4728            }),
4729            ..Default::default()
4730        };
4731        let mut tree = EntryTree(vec![
4732            e("other", None),
4733            e("g:a", Some("g")),
4734            e("g:a:b", Some("g:a")),
4735            e("unrelated", None),
4736            e("g:a:b:deep", Some("g:a:b")),
4737        ]);
4738        // Note: no explicit "g" root entry — the subtree hangs off ids alone.
4739        let renames = tree.move_entry("g:a", None, 3).unwrap();
4740        assert_eq!(
4741            renames,
4742            vec![
4743                ("g:a".to_string(), "a".to_string()),
4744                ("g:a:b".to_string(), "a:b".to_string()),
4745                ("g:a:b:deep".to_string(), "a:b:deep".to_string()),
4746            ]
4747        );
4748        let ids: Vec<&str> = tree.0.iter().map(|e| e.id.as_str()).collect();
4749        assert_eq!(ids, vec!["other", "a", "a:b", "unrelated", "a:b:deep"]);
4750        let pos = |id: &str| {
4751            tree.0
4752                .iter()
4753                .find(|e| e.id == id)
4754                .unwrap()
4755                .position
4756                .as_ref()
4757                .unwrap()
4758                .parent
4759                .clone()
4760        };
4761        assert_eq!(pos("a:b"), Some("a".to_string()), "pointer remapped");
4762        assert_eq!(pos("a:b:deep"), Some("a:b".to_string()));
4763        assert_eq!(pos("a"), None, "moved root landed at tree root");
4764    }
4765
4766    /// Moving a DISABLED group starts nothing (no phantom Begins for renamed
4767    /// ids); re-enabling it afterwards restores normal Begin lifecycle.
4768    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4769    async fn disabled_group_move_suppresses_start_then_restores() {
4770        let ctx = Context::new_root();
4771        let journal = LoaderJournal::provide_new(&ctx);
4772        move_fixture(&ctx);
4773        let ops = ctx.provide(LoaderOps::new());
4774
4775        let mut current = EntryTree(vec![]);
4776        let mut g = move_entry_spec("g", "MoveFactoryB", 1, false);
4777        g.position = Some(EntryPosition::default());
4778        let mut kid = move_entry_spec("g:kid", "MoveFactoryC", 2, false);
4779        kid.position = Some(EntryPosition {
4780            parent: Some("g".into()),
4781            position: 0,
4782        });
4783        let desired = EntryTree(vec![move_entry_spec("other", "MoveFactory", 3, false), g, kid]);
4784        let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
4785        assert!(actions.iter().all(|a| a.status.is_ok()), "{actions:?}");
4786
4787        // Disable the whole group → both fibers retire.
4788        let disabled = EntryTree(
4789            desired
4790                .0
4791                .iter()
4792                .map(|e| {
4793                    let mut c = e.clone();
4794                    if c.id == "g" || c.id == "g:kid" {
4795                        c.disabled = true;
4796                    }
4797                    c
4798                })
4799                .collect(),
4800        );
4801        let actions = Loader::apply(&ctx, &mut current, &disabled, &journal).await;
4802        assert!(actions.iter().all(|a| a.action == "retire"), "{actions:?}");
4803        assert!(journal.get("g").is_none() && journal.get("g:kid").is_none());
4804
4805        // Move the DISABLED group: the noop path re-keys nothing (no records)
4806        // and starts nothing — no phantom Begin for other:g / other:g:kid.
4807        let out = Loader::move_entry(&ctx, &mut current, &journal, "g", Some("other"), 0)
4808            .await
4809            .expect("move succeeds");
4810        assert!(out.noop);
4811        assert_eq!(
4812            out.renamed,
4813            vec![
4814                ("g".to_string(), "other:g".to_string()),
4815                ("g:kid".to_string(), "other:g:kid".to_string()),
4816            ]
4817        );
4818        assert!(journal.get("other:g").is_none());
4819        assert!(journal.get("other:g:kid").is_none());
4820        assert_eq!(
4821            ops.apply_count("other:g") + ops.apply_count("other:g:kid"),
4822            0,
4823            "moving a disabled group must not start fibers"
4824        );
4825
4826        // Restore: re-enable the moved group → Begins fire under new ids.
4827        let restored = EntryTree(vec![
4828            move_entry_spec("other", "MoveFactory", 3, false),
4829            move_entry_spec("other:g", "MoveFactoryB", 1, false),
4830            {
4831                let mut k = move_entry_spec("other:g:kid", "MoveFactoryC", 2, false);
4832                k.position = Some(EntryPosition {
4833                    parent: Some("other:g".into()),
4834                    position: 0,
4835                });
4836                k
4837            },
4838        ]);
4839        let actions = Loader::apply(&ctx, &mut current, &restored, &journal).await;
4840        assert_eq!(actions.len(), 2, "{actions:?}");
4841        assert!(actions
4842            .iter()
4843            .all(|a| a.action == "begin" && a.status.is_ok()));
4844        assert!(journal.get("other:g").unwrap().fiber_id.is_some());
4845        assert!(journal.get("other:g:kid").unwrap().fiber_id.is_some());
4846        assert!(ctx.get::<MoveProbe>().is_some());
4847    }
4848
4849    /// Every invalid move — collision with an existing id outside the moved
4850    /// subtree, unknown id, unknown target — errors WITHOUT mutating the tree.
4851    #[test]
4852    fn invalid_move_errors_without_mutating_tree() {
4853        let e = |id: &str, parent: Option<&str>| Entry {
4854            id: id.to_string(),
4855            plugin: format!("Plugin-{id}"),
4856            position: parent.map(|p| EntryPosition {
4857                parent: Some(p.to_string()),
4858                position: 0,
4859            }),
4860            ..Default::default()
4861        };
4862        let mut tree = EntryTree(vec![
4863            e("a", None),
4864            e("b", None),
4865            e("b:a", Some("b")), // occupies the id 'a' would get under 'b'
4866        ]);
4867        let snapshot = tree.clone();
4868
4869        // Collision: moving 'a' under 'b' would need the taken id 'b:a'.
4870        let err = tree.move_entry("a", Some("b"), 0).unwrap_err();
4871        assert!(err.contains("already used by plugin 'Plugin-b:a'"), "{err}");
4872        // Unknown source / target.
4873        assert!(tree.move_entry("nope", None, 0).is_err());
4874        assert!(tree.move_entry("a", Some("nope"), 0).is_err());
4875
4876        assert_eq!(tree, snapshot, "failed moves never mutate the tree");
4877    }
4878}