Skip to main content

bamboo_server/app_state/
config_runtime.rs

1use super::*;
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::time::Duration;
7
8use bamboo_config::{
9    ensure_provider_mcp_migration_ready, AtomicJsonStore, ConfigDirectoryWatcher,
10    ConfigSectionEvent, ConfigStoreError, McpSection, ProviderConfigs, SectionId,
11    SectionSourceKind, SectionStatus,
12};
13use bamboo_mcp::{McpConfig, McpServerManager, TransportConfig};
14use chrono::{DateTime, Utc};
15use serde::Serialize;
16use serde_json::Value;
17
18#[cfg(test)]
19struct ClusterAfterCommitBeforeAdoptionTestHook {
20    expected_revision: u64,
21    hook: Box<dyn FnOnce(&Path) + Send + 'static>,
22}
23
24#[cfg(test)]
25fn cluster_after_commit_before_adoption_test_hooks() -> &'static std::sync::Mutex<
26    std::collections::HashMap<PathBuf, ClusterAfterCommitBeforeAdoptionTestHook>,
27> {
28    static HOOKS: std::sync::OnceLock<
29        std::sync::Mutex<
30            std::collections::HashMap<PathBuf, ClusterAfterCommitBeforeAdoptionTestHook>,
31        >,
32    > = std::sync::OnceLock::new();
33    HOOKS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
34}
35
36#[cfg(test)]
37fn set_cluster_after_commit_before_adoption_test_hook(
38    data_dir: &Path,
39    expected_revision: u64,
40    hook: impl FnOnce(&Path) + Send + 'static,
41) {
42    cluster_after_commit_before_adoption_test_hooks()
43        .lock()
44        .unwrap_or_else(|poisoned| poisoned.into_inner())
45        .insert(
46            data_dir.to_path_buf(),
47            ClusterAfterCommitBeforeAdoptionTestHook {
48                expected_revision,
49                hook: Box::new(hook),
50            },
51        );
52}
53
54#[cfg(test)]
55fn run_cluster_after_commit_before_adoption_test_hook(data_dir: &Path, expected_revision: u64) {
56    let hook = {
57        let mut hooks = cluster_after_commit_before_adoption_test_hooks()
58            .lock()
59            .unwrap_or_else(|poisoned| poisoned.into_inner());
60        if hooks
61            .get(data_dir)
62            .is_some_and(|hook| hook.expected_revision == expected_revision)
63        {
64            hooks.remove(data_dir)
65        } else {
66            None
67        }
68    };
69    if let Some(hook) = hook {
70        (hook.hook)(data_dir);
71    }
72}
73
74#[cfg(test)]
75type CredentialCommitTestHook = Box<dyn FnOnce() + Send + 'static>;
76#[cfg(test)]
77type CredentialCommitTestHooks =
78    std::sync::Mutex<std::collections::HashMap<(PathBuf, SectionId), CredentialCommitTestHook>>;
79
80#[cfg(test)]
81fn credential_after_commit_before_live_test_hooks() -> &'static CredentialCommitTestHooks {
82    static HOOKS: std::sync::OnceLock<CredentialCommitTestHooks> = std::sync::OnceLock::new();
83    HOOKS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
84}
85
86#[cfg(test)]
87fn set_credential_after_commit_before_live_test_hook(
88    data_dir: &Path,
89    section: SectionId,
90    hook: impl FnOnce() + Send + 'static,
91) {
92    credential_after_commit_before_live_test_hooks()
93        .lock()
94        .unwrap_or_else(|poisoned| poisoned.into_inner())
95        .insert((data_dir.to_path_buf(), section), Box::new(hook));
96}
97
98#[cfg(test)]
99fn run_credential_after_commit_before_live_test_hook(data_dir: &Path, section: SectionId) {
100    let hook = credential_after_commit_before_live_test_hooks()
101        .lock()
102        .unwrap_or_else(|poisoned| poisoned.into_inner())
103        .remove(&(data_dir.to_path_buf(), section));
104    if let Some(hook) = hook {
105        hook();
106    }
107}
108
109#[cfg(test)]
110type GenericBeforeEventTestHook = Box<dyn FnOnce() + Send + 'static>;
111#[cfg(test)]
112type GenericBeforeEventTestHooks =
113    std::sync::Mutex<std::collections::HashMap<PathBuf, GenericBeforeEventTestHook>>;
114
115#[cfg(test)]
116fn generic_before_event_test_hooks() -> &'static GenericBeforeEventTestHooks {
117    static HOOKS: std::sync::OnceLock<GenericBeforeEventTestHooks> = std::sync::OnceLock::new();
118    HOOKS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
119}
120
121#[cfg(test)]
122fn set_generic_before_event_test_hook(data_dir: &Path, hook: impl FnOnce() + Send + 'static) {
123    generic_before_event_test_hooks()
124        .lock()
125        .unwrap_or_else(|poisoned| poisoned.into_inner())
126        .insert(data_dir.to_path_buf(), Box::new(hook));
127}
128
129#[cfg(test)]
130fn run_generic_before_event_test_hook(data_dir: &Path) {
131    let hook = generic_before_event_test_hooks()
132        .lock()
133        .unwrap_or_else(|poisoned| poisoned.into_inner())
134        .remove(data_dir);
135    if let Some(hook) = hook {
136        hook();
137    }
138}
139
140#[cfg(test)]
141type GenericBeforeProviderPublishTestHook = Box<dyn FnOnce() + Send + 'static>;
142#[cfg(test)]
143type GenericBeforeProviderPublishTestHooks =
144    std::sync::Mutex<std::collections::HashMap<PathBuf, GenericBeforeProviderPublishTestHook>>;
145
146#[cfg(test)]
147fn generic_before_provider_publish_test_hooks() -> &'static GenericBeforeProviderPublishTestHooks {
148    static HOOKS: std::sync::OnceLock<GenericBeforeProviderPublishTestHooks> =
149        std::sync::OnceLock::new();
150    HOOKS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
151}
152
153#[cfg(test)]
154fn set_generic_before_provider_publish_test_hook(
155    data_dir: &Path,
156    hook: impl FnOnce() + Send + 'static,
157) {
158    generic_before_provider_publish_test_hooks()
159        .lock()
160        .unwrap_or_else(|poisoned| poisoned.into_inner())
161        .insert(data_dir.to_path_buf(), Box::new(hook));
162}
163
164#[cfg(test)]
165fn run_generic_before_provider_publish_test_hook(data_dir: &Path) {
166    let hook = generic_before_provider_publish_test_hooks()
167        .lock()
168        .unwrap_or_else(|poisoned| poisoned.into_inner())
169        .remove(data_dir);
170    if let Some(hook) = hook {
171        hook();
172    }
173}
174
175struct FacadeRuntimeMaterialization {
176    config: Config,
177    failures: BTreeSet<SectionId>,
178}
179
180fn materialize_facade_effective_config(
181    facade: &bamboo_config::ConfigFacade,
182    data_dir: &Path,
183) -> FacadeRuntimeMaterialization {
184    let mut config = facade.effective_config();
185    let mut failures = BTreeSet::new();
186    if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
187        tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
188        config.proxy_auth = None;
189        failures.insert(SectionId::Core);
190    }
191    if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
192        tracing::warn!(error = %error, "provider credential hydration unavailable");
193        failures.insert(SectionId::Providers);
194    }
195    if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
196        tracing::warn!(error = %error, "MCP credential hydration unavailable");
197        failures.insert(SectionId::Mcp);
198    }
199    if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
200        tracing::warn!(error = %error, "env credential hydration unavailable");
201        for entry in &mut config.env_vars {
202            if entry.secret {
203                entry.value.clear();
204            }
205        }
206        failures.insert(SectionId::Env);
207    }
208    if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
209        tracing::warn!(error = %error, "cluster credential hydration unavailable");
210        failures.insert(SectionId::ClusterFabric);
211    }
212    if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
213        tracing::warn!(error = %error, "notification credential hydration unavailable");
214        config.notifications.ntfy.token = None;
215        config.notifications.bark.device_key = None;
216        failures.insert(SectionId::Notifications);
217    }
218    if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
219        tracing::warn!(error = %error, "connect credential hydration unavailable");
220        for platform in &mut config.connect.platforms {
221            platform.token = None;
222            platform.app_secret = None;
223        }
224        failures.insert(SectionId::Connect);
225    }
226    if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
227        tracing::warn!(error = %error, "access-control credential hydration unavailable");
228        config.clear_access_control_runtime_verifiers();
229        failures.insert(SectionId::AccessControl);
230    }
231    if config
232        .access_control
233        .as_ref()
234        .is_some_and(|access| access.repair_required)
235    {
236        failures.insert(SectionId::AccessControl);
237    }
238    if let Some(broker) = config.subagents_mut().broker.as_mut() {
239        if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
240            tracing::warn!(error = %error, "external broker credential hydration unavailable");
241            broker.token.clear();
242            failures.insert(SectionId::Subagents);
243        }
244    }
245    config.apply_runtime_env_overrides();
246    FacadeRuntimeMaterialization { config, failures }
247}
248
249pub(super) fn load_facade_effective_config(
250    facade: &bamboo_config::ConfigFacade,
251    data_dir: &Path,
252) -> Config {
253    let materialized = materialize_facade_effective_config(facade, data_dir);
254    for section in &materialized.failures {
255        facade.registry().mark_runtime_degraded(
256            *section,
257            "configuration runtime credential repair is required",
258        );
259    }
260    materialized.config
261}
262
263fn load_committed_effective_config(data_dir: &Path) -> Result<Config, ConfigStoreError> {
264    if bamboo_config::section_layout_is_active(data_dir)? {
265        let facade = bamboo_config::ConfigFacade::open(data_dir)?;
266        let config = load_facade_effective_config(&facade, data_dir);
267        Ok(config)
268    } else {
269        Ok(Config::from_data_dir_without_publish(Some(
270            data_dir.to_path_buf(),
271        )))
272    }
273}
274
275/// Health metadata for the server-owned effective/provider configuration view.
276#[derive(Debug, Clone, Serialize)]
277pub struct ConfigLiveHealth {
278    pub revision: u64,
279    pub loaded_at: DateTime<Utc>,
280    pub source_path: PathBuf,
281    pub source_kind: SectionSourceKind,
282    pub status: SectionStatus,
283    pub last_error: Option<String>,
284}
285
286/// Owns the blocking filesystem watcher and async runtime-apply task.
287pub struct ConfigWatcherRuntime {
288    stop: Arc<AtomicBool>,
289    watcher_task: Option<std::thread::JoinHandle<()>>,
290    apply_task: Option<tokio::task::JoinHandle<()>>,
291}
292
293struct ConfigPathChanges {
294    paths: Vec<PathBuf>,
295    initial_mcp: bool,
296}
297
298impl ConfigWatcherRuntime {
299    #[allow(clippy::too_many_arguments)]
300    pub fn start(
301        data_dir: PathBuf,
302        config: Arc<RwLock<Config>>,
303        config_facade: Option<Arc<bamboo_config::ConfigFacade>>,
304        config_io_lock: Arc<tokio::sync::Mutex<()>>,
305        provider_registry: Arc<bamboo_llm::ProviderRegistry>,
306        provider: Arc<RwLock<Arc<dyn LLMProvider>>>,
307        mcp_manager: Arc<McpServerManager>,
308        account_sink: Arc<bamboo_engine::events::AccountEventSink>,
309    ) -> (
310        Self,
311        Arc<std::sync::RwLock<ConfigLiveHealth>>,
312        Arc<std::sync::RwLock<ConfigLiveHealth>>,
313    ) {
314        let provider_store = AtomicJsonStore::new(data_dir.join("providers.json"), 1);
315        let provider_health = Arc::new(std::sync::RwLock::new(initial_provider_health(
316            &provider_store,
317        )));
318        let mcp_store = AtomicJsonStore::new(data_dir.join("mcp.json"), 1);
319        let mcp_health = Arc::new(std::sync::RwLock::new(initial_mcp_health(&mcp_store)));
320        let stop = Arc::new(AtomicBool::new(false));
321        let watcher = match ConfigDirectoryWatcher::watch(&data_dir, Duration::from_millis(120)) {
322            Ok(watcher) => watcher,
323            Err(error) => {
324                tracing::warn!(error = %error, "live configuration watcher could not start");
325                {
326                    let mut value = provider_health
327                        .write()
328                        .unwrap_or_else(|poisoned| poisoned.into_inner());
329                    value.status = SectionStatus::Degraded;
330                    value.last_error = Some("configuration watcher unavailable".to_string());
331                }
332                {
333                    let mut value = mcp_health
334                        .write()
335                        .unwrap_or_else(|poisoned| poisoned.into_inner());
336                    value.status = SectionStatus::Degraded;
337                    value.last_error = Some("configuration watcher unavailable".to_string());
338                }
339                return (
340                    Self {
341                        stop,
342                        watcher_task: None,
343                        apply_task: None,
344                    },
345                    provider_health,
346                    mcp_health,
347                );
348            }
349        };
350
351        // The filesystem side must never block in send: Drop joins this OS
352        // thread after aborting the async consumer, so a bounded blocking_send
353        // could deadlock shutdown if its queue were full.
354        let self_write_marker = watcher.self_write_marker();
355        let (changes_tx, mut changes_rx) =
356            tokio::sync::mpsc::unbounded_channel::<ConfigPathChanges>();
357        let initial_changes = changes_tx.clone();
358        let worker_stop = stop.clone();
359        let watcher_task = std::thread::spawn(move || {
360            while !worker_stop.load(Ordering::Relaxed) {
361                match watcher.recv_timeout(Duration::from_millis(250)) {
362                    Ok(paths) => {
363                        if changes_tx
364                            .send(ConfigPathChanges {
365                                paths,
366                                initial_mcp: false,
367                            })
368                            .is_err()
369                        {
370                            break;
371                        }
372                    }
373                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
374                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
375                }
376            }
377        });
378
379        // A sidecar may already exist before the server starts. Queue it through
380        // the exact same candidate/runtime transaction as later filesystem
381        // events; parse-only initial health must never be mistaken for a
382        // published runtime snapshot.
383        let initial_mcp_path = data_dir.join("mcp.json");
384        if initial_mcp_path.exists() {
385            let _ = initial_changes.send(ConfigPathChanges {
386                paths: vec![initial_mcp_path],
387                initial_mcp: true,
388            });
389        }
390
391        let apply_provider_health = provider_health.clone();
392        let apply_mcp_health = mcp_health.clone();
393        let apply_task = tokio::spawn(async move {
394            while let Some(changes) = changes_rx.recv().await {
395                let watched_sections = config_facade
396                    .as_ref()
397                    .map(|_| {
398                        changes
399                            .paths
400                            .iter()
401                            .filter_map(|path| {
402                                path.file_name()
403                                    .and_then(|name| name.to_str())
404                                    .and_then(SectionId::from_file_name)
405                            })
406                            .collect::<BTreeSet<_>>()
407                    })
408                    .unwrap_or_default();
409                let provider_watched = changes.paths.iter().any(|path| {
410                    path.file_name().and_then(|name| name.to_str()) == Some("providers.json")
411                });
412                let mcp_watched = changes.paths.iter().any(|path| {
413                    path.file_name().and_then(|name| name.to_str()) == Some("mcp.json")
414                });
415                let ordinary_watched = watched_sections
416                    .iter()
417                    .copied()
418                    .filter(|id| !matches!(id, SectionId::Providers | SectionId::Mcp))
419                    .collect::<Vec<_>>();
420                if !provider_watched && !mcp_watched && ordinary_watched.is_empty() {
421                    continue;
422                }
423
424                // Serialize candidate construction and publication with config
425                // writers. Otherwise a slow provider build could later publish
426                // a clone taken before an unrelated API update and clobber it.
427                let _io = config_io_lock.lock().await;
428                if let Some(facade) = config_facade.as_ref() {
429                    reload_and_apply_ordinary_sections(
430                        &data_dir,
431                        &config,
432                        facade,
433                        &account_sink,
434                        ordinary_watched,
435                    )
436                    .await;
437                }
438                if provider_watched {
439                    if let Some(facade) = config_facade.as_ref() {
440                        wait_for_section_file_settle(&data_dir, SectionId::Providers).await;
441                        if let Some(event) =
442                            facade.registry().reload_if_changed(SectionId::Providers)
443                        {
444                            if matches!(event, ConfigSectionEvent::Invalid { .. }) {
445                                publish_section_failure(
446                                &apply_provider_health,
447                                &account_sink,
448                                "providers",
449                                facade.registry().providers.snapshot().status,
450                                "provider section is invalid; retaining last-known-good runtime"
451                                    .to_string(),
452                            );
453                            } else {
454                                self_write_marker.mark_self_write(provider_store.path());
455                                let materialized =
456                                    materialize_facade_effective_config(facade, &data_dir);
457                                if materialized.failures.contains(&SectionId::Providers) {
458                                    facade.registry().mark_runtime_degraded(
459                                    SectionId::Providers,
460                                    "provider credential hydration failed; retaining last-known-good runtime",
461                                );
462                                    publish_section_failure(
463                                    &apply_provider_health,
464                                    &account_sink,
465                                    "providers",
466                                    SectionStatus::Degraded,
467                                    "provider credential hydration failed; retaining last-known-good runtime"
468                                        .to_string(),
469                                );
470                                } else {
471                                    let mut candidate = config.read().await.clone();
472                                    apply_runtime_section(
473                                        SectionId::Providers,
474                                        &materialized.config,
475                                        &mut candidate,
476                                    );
477                                    match prepare_provider_candidate(candidate, &data_dir).await {
478                                        Ok((candidate, registry, next_provider)) => {
479                                            let mut live_config = config.write().await;
480                                            let mut live_provider = provider.write().await;
481                                            let recovered =
482                                                section_is_unhealthy(&apply_provider_health);
483                                            candidate.publish_env_vars();
484                                            *live_config = candidate;
485                                            provider_registry.replace_with(registry);
486                                            *live_provider = next_provider;
487                                            drop(live_provider);
488                                            drop(live_config);
489                                            let revision =
490                                                facade.registry().providers.snapshot().revision;
491                                            publish_section_success(
492                                                &apply_provider_health,
493                                                &account_sink,
494                                                "providers",
495                                                data_dir.join("providers.json"),
496                                                recovered,
497                                                Some(revision),
498                                            );
499                                        }
500                                        Err(_) => {
501                                            facade.registry().mark_runtime_degraded(
502                                            SectionId::Providers,
503                                            "provider runtime initialization failed; retaining last-known-good runtime",
504                                        );
505                                            publish_section_failure(
506                                            &apply_provider_health,
507                                            &account_sink,
508                                            "providers",
509                                            SectionStatus::Degraded,
510                                            "provider runtime initialization failed; retaining last-known-good runtime"
511                                                .to_string(),
512                                        );
513                                        }
514                                    }
515                                }
516                            }
517                        }
518                    } else {
519                        let current_config = config.read().await.clone();
520                        let current_revision = apply_provider_health
521                            .read()
522                            .unwrap_or_else(|poisoned| poisoned.into_inner())
523                            .revision;
524                        let result = load_and_prepare_provider_candidate(
525                            &provider_store,
526                            current_revision,
527                            current_config,
528                        )
529                        .await;
530                        match result {
531                            Ok(candidate) if candidate.unchanged => {}
532                            Ok(candidate) => {
533                                if candidate.normalized_external_revision {
534                                    self_write_marker.mark_self_write(provider_store.path());
535                                }
536                                let mut live_config = config.write().await;
537                                let mut live_provider = provider.write().await;
538                                let recovered = section_is_unhealthy(&apply_provider_health);
539                                candidate.config.publish_env_vars();
540                                *live_config = candidate.config;
541                                provider_registry.replace_with(candidate.registry);
542                                *live_provider = candidate.provider;
543                                drop(live_provider);
544                                drop(live_config);
545
546                                publish_section_success(
547                                    &apply_provider_health,
548                                    &account_sink,
549                                    "providers",
550                                    data_dir.join("providers.json"),
551                                    recovered,
552                                    Some(candidate.revision),
553                                );
554                            }
555                            Err(error) => publish_section_failure(
556                                &apply_provider_health,
557                                &account_sink,
558                                "providers",
559                                candidate_error_status(&error.kind),
560                                error.message,
561                            ),
562                        }
563                    }
564                }
565
566                if mcp_watched {
567                    if let Some(facade) = config_facade.as_ref() {
568                        wait_for_section_file_settle(&data_dir, SectionId::Mcp).await;
569                        let event =
570                            facade
571                                .registry()
572                                .reload_if_changed(SectionId::Mcp)
573                                .or_else(|| {
574                                    changes.initial_mcp.then(|| ConfigSectionEvent::Changed {
575                                        section: "mcp".to_string(),
576                                        revision: facade.registry().mcp.snapshot().revision,
577                                    })
578                                });
579                        let Some(event) = event else {
580                            continue;
581                        };
582                        if matches!(event, ConfigSectionEvent::Invalid { .. }) {
583                            publish_section_failure(
584                                &apply_mcp_health,
585                                &account_sink,
586                                "mcp",
587                                facade.registry().mcp.snapshot().status,
588                                "MCP section is invalid; retaining last-known-good runtime"
589                                    .to_string(),
590                            );
591                        } else {
592                            self_write_marker.mark_self_write(mcp_store.path());
593                            let materialized =
594                                materialize_facade_effective_config(facade, &data_dir);
595                            if materialized.failures.contains(&SectionId::Mcp) {
596                                facade.registry().mark_runtime_degraded(
597                                    SectionId::Mcp,
598                                    "MCP credential hydration failed; retaining last-known-good runtime",
599                                );
600                                publish_section_failure(
601                                    &apply_mcp_health,
602                                    &account_sink,
603                                    "mcp",
604                                    SectionStatus::Degraded,
605                                    "MCP credential hydration failed; retaining last-known-good runtime"
606                                        .to_string(),
607                                );
608                                continue;
609                            }
610                            let next_mcp = materialized.config.mcp.clone();
611                            let publish_config = config.clone();
612                            match mcp_manager
613                                .reconcile_from_config_transactional_after(
614                                    &materialized.config.mcp,
615                                    || async move {
616                                        publish_config.write().await.mcp = next_mcp;
617                                        Ok(())
618                                    },
619                                )
620                                .await
621                            {
622                                Ok(()) => {
623                                    let recovered = section_is_unhealthy(&apply_mcp_health);
624                                    let snapshot = facade.registry().mcp.snapshot();
625                                    let revision = snapshot.revision;
626                                    if changes.initial_mcp
627                                        && snapshot.status == SectionStatus::Healthy
628                                    {
629                                        // Startup reconciliation makes the
630                                        // already-materialized section live in
631                                        // the MCP manager; it is not a config
632                                        // mutation and must not consume an
633                                        // account-feed sequence number.
634                                        set_live_health_revision(
635                                            &apply_mcp_health,
636                                            revision,
637                                            Some((
638                                                data_dir.join("mcp.json"),
639                                                SectionSourceKind::File,
640                                            )),
641                                        );
642                                    } else {
643                                        publish_section_success(
644                                            &apply_mcp_health,
645                                            &account_sink,
646                                            "mcp",
647                                            data_dir.join("mcp.json"),
648                                            recovered,
649                                            Some(revision),
650                                        );
651                                    }
652                                }
653                                Err(_) => {
654                                    facade.registry().mark_runtime_degraded(
655                                        SectionId::Mcp,
656                                        "MCP runtime initialization failed; retaining last-known-good runtime",
657                                    );
658                                    publish_section_failure(
659                                        &apply_mcp_health,
660                                        &account_sink,
661                                        "mcp",
662                                        SectionStatus::Degraded,
663                                        "MCP runtime initialization failed; retaining last-known-good runtime"
664                                            .to_string(),
665                                    )
666                                }
667                            }
668                        }
669                    } else {
670                        let current_config = config.read().await.clone();
671                        let current_revision = apply_mcp_health
672                            .read()
673                            .unwrap_or_else(|poisoned| poisoned.into_inner())
674                            .revision;
675                        let result = load_and_validate_mcp_candidate(
676                            &mcp_store,
677                            current_revision,
678                            current_config,
679                            changes.initial_mcp,
680                        )
681                        .await;
682                        match result {
683                            Ok(candidate) if candidate.unchanged => {}
684                            Ok(candidate) => {
685                                if candidate.normalized_external_revision
686                                    || candidate.source_kind == SectionSourceKind::Backup
687                                {
688                                    // Normalization writes the primary and backup
689                                    // recovery quarantines it. Suppress only that
690                                    // exact watcher echo; a later external write has
691                                    // a different fingerprint and remains visible.
692                                    self_write_marker.mark_self_write(mcp_store.path());
693                                }
694                                let next_mcp = candidate.config.mcp.clone();
695                                let publish_config = config.clone();
696                                match mcp_manager
697                                    .reconcile_from_config_transactional_after(
698                                        &candidate.config.mcp,
699                                        || async move {
700                                            publish_config.write().await.mcp = next_mcp;
701                                            Ok(())
702                                        },
703                                    )
704                                    .await
705                                {
706                                    Ok(()) => {
707                                        let recovered = section_is_unhealthy(&apply_mcp_health);
708                                        if candidate.source_kind == SectionSourceKind::Backup {
709                                            publish_mcp_backup_lkg(
710                                                &apply_mcp_health,
711                                                &account_sink,
712                                                candidate.source_path,
713                                                candidate.revision,
714                                            );
715                                        } else {
716                                            publish_section_success(
717                                                &apply_mcp_health,
718                                                &account_sink,
719                                                "mcp",
720                                                data_dir.join("mcp.json"),
721                                                recovered,
722                                                Some(candidate.revision),
723                                            );
724                                        }
725                                    }
726                                    Err(_) => publish_section_failure(
727                                        &apply_mcp_health,
728                                        &account_sink,
729                                        "mcp",
730                                        SectionStatus::Degraded,
731                                        "MCP runtime initialization failed; retaining last-known-good runtime"
732                                            .to_string(),
733                                    ),
734                                }
735                            }
736                            Err(error) => publish_section_failure(
737                                &apply_mcp_health,
738                                &account_sink,
739                                "mcp",
740                                candidate_error_status(&error.kind),
741                                error.message,
742                            ),
743                        }
744                    }
745                }
746            }
747        });
748
749        (
750            Self {
751                stop,
752                watcher_task: Some(watcher_task),
753                apply_task: Some(apply_task),
754            },
755            provider_health,
756            mcp_health,
757        )
758    }
759}
760
761async fn wait_for_section_file_settle(data_dir: &Path, id: SectionId) {
762    let path = data_dir.join(id.descriptor().file_name);
763    for _ in 0..3 {
764        if path.exists() {
765            return;
766        }
767        tokio::time::sleep(Duration::from_millis(50)).await;
768    }
769}
770
771/// Reload ordinary section authorities, install every successfully hydrated
772/// runtime generation, and only then publish its registry event.
773///
774/// The caller owns `config_io_lock`; keeping this sequence shared by the live
775/// watcher and focused regressions makes the event/runtime ordering explicit.
776async fn reload_and_apply_ordinary_sections(
777    data_dir: &Path,
778    config: &Arc<RwLock<Config>>,
779    facade: &bamboo_config::ConfigFacade,
780    account_sink: &bamboo_engine::events::AccountEventSink,
781    sections: impl IntoIterator<Item = SectionId>,
782) {
783    let mut publishable = Vec::new();
784    for id in sections {
785        wait_for_section_file_settle(data_dir, id).await;
786        let Some(event) = facade.registry().reload_if_changed(id) else {
787            continue;
788        };
789        if matches!(event, ConfigSectionEvent::Invalid { .. }) {
790            publish_registry_event(account_sink, &event);
791        } else {
792            publishable.push((id, event));
793        }
794    }
795    if publishable.is_empty() {
796        return;
797    }
798
799    let materialized = materialize_facade_effective_config(facade, data_dir);
800    let mut current = config.read().await.clone();
801    let mut applied = Vec::new();
802    for (id, event) in publishable {
803        if materialized.failures.contains(&id) {
804            if let Some(invalid) = facade.registry().mark_runtime_degraded(
805                id,
806                "configuration runtime hydration failed; retaining last-known-good runtime",
807            ) {
808                publish_registry_event(account_sink, &invalid);
809            }
810            continue;
811        }
812        apply_runtime_section(id, &materialized.config, &mut current);
813        applied.push((id, event));
814    }
815    if applied.is_empty() {
816        return;
817    }
818
819    let publishes_env = applied.iter().any(|(id, _)| *id == SectionId::Env);
820    let enforcement_newly_off = !config.read().await.plugin_trust.enforcement_is_off()
821        && current.plugin_trust.enforcement_is_off();
822    *config.write().await = current.clone();
823    if publishes_env {
824        current.publish_env_vars();
825    }
826    if enforcement_newly_off {
827        warn_plugin_trust_enforcement_off();
828    }
829    for (_, event) in applied {
830        publish_registry_event(account_sink, &event);
831    }
832}
833
834pub(super) fn publish_registry_event(
835    account_sink: &bamboo_engine::events::AccountEventSink,
836    event: &ConfigSectionEvent,
837) {
838    let event = match event {
839        ConfigSectionEvent::Changed { section, revision } => AgentEvent::ConfigChanged {
840            section: section.clone(),
841            revision: *revision,
842        },
843        ConfigSectionEvent::Invalid { section, revision } => AgentEvent::ConfigInvalid {
844            section: section.clone(),
845            revision: *revision,
846        },
847        ConfigSectionEvent::Recovered { section, revision } => AgentEvent::ConfigRecovered {
848            section: section.clone(),
849            revision: *revision,
850        },
851    };
852    account_sink.record(None, &event);
853}
854
855fn publish_exact_facade_events(
856    account_sink: &bamboo_engine::events::AccountEventSink,
857    events: &[ConfigSectionEvent],
858) -> Result<(), AppError> {
859    for event in events {
860        publish_registry_event(account_sink, event);
861        if matches!(event, ConfigSectionEvent::Invalid { .. }) {
862            return Err(AppError::InternalError(anyhow::anyhow!(
863                "committed configuration section became invalid before publication"
864            )));
865        }
866    }
867    Ok(())
868}
869
870struct InstalledCredentialSectionCommit {
871    events: Vec<ConfigSectionEvent>,
872    metadata: bamboo_config::CredentialSectionRuntimeMetadata,
873    section: Option<bamboo_config::SectionEnvelope<Value>>,
874}
875
876pub(crate) struct ExactCredentialSectionSnapshot {
877    pub config: Config,
878    pub section: bamboo_config::SectionEnvelope<Value>,
879    pub metadata: bamboo_config::CredentialSectionRuntimeMetadata,
880}
881
882fn map_exact_credential_store_error(error: ConfigStoreError) -> AppError {
883    match error {
884        ConfigStoreError::Conflict { expected, actual } => {
885            AppError::ConfigConflict { expected, actual }
886        }
887        ConfigStoreError::Validation(message) => AppError::BadRequest(message),
888        ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(anyhow::anyhow!(
889            "configuration commit outcome is indeterminate: {message}"
890        )),
891        ConfigStoreError::Io(error) => AppError::StorageError(error),
892        ConfigStoreError::Json(_) => {
893            AppError::BadRequest("configuration document is invalid".to_string())
894        }
895        ConfigStoreError::Watch(error) => {
896            AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
897        }
898    }
899}
900
901async fn install_exact_credential_section_mutation_base(
902    data_dir: PathBuf,
903    section: SectionId,
904    expected_revision: u64,
905    target: &mut Config,
906) -> Result<bamboo_config::CredentialSectionRuntimeMetadata, AppError> {
907    let exact = tokio::task::spawn_blocking(move || {
908        bamboo_config::read_exact_credential_section_snapshot(
909            data_dir,
910            section,
911            Some(expected_revision),
912        )
913    })
914    .await
915    .map_err(|error| {
916        AppError::InternalError(anyhow::anyhow!(
917            "{} exact mutation snapshot task failed: {error}",
918            section.descriptor().name
919        ))
920    })?
921    .map_err(map_exact_credential_store_error)?;
922    Ok(exact.install_into(target))
923}
924
925fn read_credential_runtime_metadata(
926    data_dir: &std::path::Path,
927) -> Result<bamboo_config::CredentialSectionRuntimeMetadata, ConfigStoreError> {
928    let (credential_statuses, credential_health) =
929        bamboo_config::CredentialStore::open(data_dir).statuses_with_health()?;
930    Ok(bamboo_config::CredentialSectionRuntimeMetadata {
931        credential_statuses,
932        credential_health,
933    })
934}
935
936fn install_credential_section_commit(
937    commit: bamboo_config::CredentialSectionTransactionCommit,
938    target: &mut Config,
939) -> Result<InstalledCredentialSectionCommit, ConfigStoreError> {
940    let bamboo_config::CredentialSectionTransactionCommit {
941        revision: _,
942        section_adoption,
943        credential_adoption,
944        section,
945        runtime,
946    } = commit;
947    let section = section?;
948    let metadata = runtime?.install_into(target);
949    let mut events = Vec::new();
950    if let Some(adoption) = credential_adoption {
951        if let Some(event) = adoption? {
952            events.push(event);
953        }
954    }
955    if let Some(adoption) = section_adoption {
956        events.push(adoption?);
957    }
958    Ok(InstalledCredentialSectionCommit {
959        events,
960        metadata,
961        section: Some(section),
962    })
963}
964
965fn install_facade_config_commit(
966    commit: bamboo_config::FacadeConfigCommit,
967    target: &mut Config,
968) -> Result<Vec<ConfigSectionEvent>, ConfigStoreError> {
969    let bamboo_config::FacadeConfigCommit {
970        section_adoption,
971        runtime,
972    } = commit;
973    if let Some(runtime) = runtime? {
974        runtime.install_into(target);
975    }
976    section_adoption
977        .map(|adoption| adoption.map(|event| vec![event]))
978        .unwrap_or_else(|| Ok(Vec::new()))
979}
980
981fn apply_runtime_section(id: SectionId, source: &Config, target: &mut Config) {
982    match id {
983        SectionId::Core => {
984            target.http_proxy = source.http_proxy.clone();
985            target.https_proxy = source.https_proxy.clone();
986            target.proxy_auth = source.proxy_auth.clone();
987            target.proxy_auth_encrypted = None;
988            target.proxy_auth_credential_ref = source.proxy_auth_credential_ref.clone();
989            target.headless_auth = source.headless_auth;
990            target.server = source.server.clone();
991            target.default_work_area = source.default_work_area.clone();
992            target.run_budget = source.run_budget;
993            target.stream_timeout = source.stream_timeout;
994            target.extra = source.extra.clone();
995        }
996        SectionId::Providers => {
997            target.provider = source.provider.clone();
998            target.defaults = source.defaults.clone();
999            target.provider_instances = source.provider_instances.clone();
1000            target.default_provider_instance = source.default_provider_instance.clone();
1001            target.features = source.features.clone();
1002            *target.providers_mut() = source.providers().clone();
1003        }
1004        SectionId::Mcp => target.mcp = source.mcp.clone(),
1005        SectionId::ToolsSkills => {
1006            target.tools = source.tools.clone();
1007            target.skills = source.skills.clone();
1008            target.plugin_trust = source.plugin_trust.clone();
1009        }
1010        SectionId::Memory => *target.memory_mut() = source.memory().clone(),
1011        SectionId::Subagents => *target.subagents_mut() = source.subagents().clone(),
1012        SectionId::Notifications => target.notifications = source.notifications.clone(),
1013        SectionId::Connect => target.connect = source.connect.clone(),
1014        SectionId::ClusterFabric => target.cluster_fabric = source.cluster_fabric.clone(),
1015        SectionId::Env => target.env_vars = source.env_vars.clone(),
1016        SectionId::AccessControl => target.access_control = source.access_control.clone(),
1017        SectionId::Hooks => target.hooks = source.hooks.clone(),
1018        SectionId::ModelPolicy => {
1019            target.keyword_masking = source.keyword_masking.clone();
1020            target.anthropic_model_mapping = source.anthropic_model_mapping.clone();
1021            target.gemini_model_mapping = source.gemini_model_mapping.clone();
1022        }
1023        SectionId::ModelLimits | SectionId::Credentials => {}
1024    }
1025}
1026
1027/// Compatibility config writers never own the cluster-fabric section. Rebase
1028/// their detached candidate on the process facade's last-known-good cluster
1029/// projection so runtime-only heartbeat updates cannot become an unrevisioned
1030/// durable cluster mutation.
1031fn restore_authoritative_cluster_fabric(
1032    facade: Option<&std::sync::Arc<bamboo_config::ConfigFacade>>,
1033    candidate: &mut Config,
1034) {
1035    if let Some(facade) = facade {
1036        candidate.cluster_fabric = facade.registry().cluster_fabric.snapshot().data.0.clone();
1037    }
1038}
1039
1040fn section_is_unhealthy(health: &std::sync::RwLock<ConfigLiveHealth>) -> bool {
1041    health
1042        .read()
1043        .unwrap_or_else(|poisoned| poisoned.into_inner())
1044        .status
1045        != SectionStatus::Healthy
1046}
1047
1048fn publish_section_success(
1049    health: &std::sync::RwLock<ConfigLiveHealth>,
1050    account_sink: &bamboo_engine::events::AccountEventSink,
1051    section: &str,
1052    source_path: PathBuf,
1053    recovered: bool,
1054    revision: Option<u64>,
1055) {
1056    let revision = match revision {
1057        Some(revision) => set_live_health_revision(
1058            health,
1059            revision,
1060            Some((source_path, SectionSourceKind::File)),
1061        ),
1062        None => update_live_health(
1063            health,
1064            SectionStatus::Healthy,
1065            None,
1066            true,
1067            Some((source_path, SectionSourceKind::File)),
1068        ),
1069    };
1070    let event = if recovered {
1071        AgentEvent::ConfigRecovered {
1072            section: section.to_string(),
1073            revision,
1074        }
1075    } else {
1076        AgentEvent::ConfigChanged {
1077            section: section.to_string(),
1078            revision,
1079        }
1080    };
1081    account_sink.record(None, &event);
1082}
1083
1084fn publish_section_failure(
1085    health: &std::sync::RwLock<ConfigLiveHealth>,
1086    account_sink: &bamboo_engine::events::AccountEventSink,
1087    section: &str,
1088    status: SectionStatus,
1089    message: String,
1090) {
1091    let revision = update_live_health(health, status, Some(message), false, None);
1092    account_sink.record(
1093        None,
1094        &AgentEvent::ConfigInvalid {
1095            section: section.to_string(),
1096            revision,
1097        },
1098    );
1099}
1100
1101fn publish_mcp_backup_lkg(
1102    health: &std::sync::RwLock<ConfigLiveHealth>,
1103    account_sink: &bamboo_engine::events::AccountEventSink,
1104    source_path: PathBuf,
1105    revision: u64,
1106) {
1107    {
1108        let mut health = health
1109            .write()
1110            .unwrap_or_else(|poisoned| poisoned.into_inner());
1111        health.revision = revision;
1112        health.loaded_at = Utc::now();
1113        health.source_path = source_path;
1114        health.source_kind = SectionSourceKind::Backup;
1115        health.status = SectionStatus::Degraded;
1116        health.last_error =
1117            Some("primary MCP section invalid; running last-known-good backup runtime".to_string());
1118    }
1119    account_sink.record(
1120        None,
1121        &AgentEvent::ConfigInvalid {
1122            section: "mcp".to_string(),
1123            revision,
1124        },
1125    );
1126}
1127
1128fn initial_provider_health(store: &AtomicJsonStore<ProviderConfigs>) -> ConfigLiveHealth {
1129    if ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
1130        .is_err()
1131    {
1132        return ConfigLiveHealth {
1133            revision: 0,
1134            loaded_at: Utc::now(),
1135            source_path: store.path().to_path_buf(),
1136            source_kind: SectionSourceKind::File,
1137            status: SectionStatus::Degraded,
1138            last_error: Some("provider/MCP credential migration is pending".to_string()),
1139        };
1140    }
1141    match store.load_validated_allowing_unversioned(|_| Ok(())) {
1142        Ok(Some(stored)) => ConfigLiveHealth {
1143            revision: stored.revision,
1144            loaded_at: Utc::now(),
1145            source_path: stored.source_path,
1146            source_kind: if stored.recovered_from_backup {
1147                SectionSourceKind::Backup
1148            } else {
1149                SectionSourceKind::File
1150            },
1151            status: if stored.recovered_from_backup {
1152                SectionStatus::Degraded
1153            } else {
1154                SectionStatus::Healthy
1155            },
1156            last_error: stored.recovered_from_backup.then(|| {
1157                "primary provider section invalid; using last-known-good backup".to_string()
1158            }),
1159        },
1160        Ok(None) => ConfigLiveHealth {
1161            revision: 0,
1162            loaded_at: Utc::now(),
1163            source_path: store.path().to_path_buf(),
1164            source_kind: SectionSourceKind::Default,
1165            status: SectionStatus::Missing,
1166            last_error: None,
1167        },
1168        Err(_) => ConfigLiveHealth {
1169            revision: 0,
1170            loaded_at: Utc::now(),
1171            source_path: store.path().to_path_buf(),
1172            source_kind: SectionSourceKind::File,
1173            status: SectionStatus::Invalid,
1174            last_error: Some("provider section could not be parsed or read".to_string()),
1175        },
1176    }
1177}
1178
1179fn initial_mcp_health(store: &AtomicJsonStore<McpConfig>) -> ConfigLiveHealth {
1180    if ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
1181        .is_err()
1182    {
1183        return ConfigLiveHealth {
1184            revision: 0,
1185            loaded_at: Utc::now(),
1186            source_path: store.path().to_path_buf(),
1187            source_kind: SectionSourceKind::File,
1188            status: SectionStatus::Degraded,
1189            last_error: Some("provider/MCP credential migration is pending".to_string()),
1190        };
1191    }
1192    match store.load_validated(validate_mcp_config) {
1193        Ok(Some(stored)) => ConfigLiveHealth {
1194            // The document is only a parsed candidate until runtime staging
1195            // succeeds; the published LKG revision remains zero meanwhile.
1196            revision: 0,
1197            loaded_at: Utc::now(),
1198            source_path: store.path().to_path_buf(),
1199            source_kind: if stored.recovered_from_backup {
1200                SectionSourceKind::Backup
1201            } else {
1202                SectionSourceKind::File
1203            },
1204            status: SectionStatus::Degraded,
1205            last_error: Some(if stored.recovered_from_backup {
1206                "primary MCP section invalid; runtime initialization pending from backup"
1207                    .to_string()
1208            } else {
1209                "MCP runtime initialization pending".to_string()
1210            }),
1211        },
1212        Ok(None) => ConfigLiveHealth {
1213            revision: 0,
1214            loaded_at: Utc::now(),
1215            source_path: store.path().to_path_buf(),
1216            source_kind: SectionSourceKind::Default,
1217            status: SectionStatus::Missing,
1218            last_error: None,
1219        },
1220        Err(_) => ConfigLiveHealth {
1221            revision: 0,
1222            loaded_at: Utc::now(),
1223            source_path: store.path().to_path_buf(),
1224            source_kind: SectionSourceKind::File,
1225            status: SectionStatus::Invalid,
1226            last_error: Some("MCP section could not be parsed or validated".to_string()),
1227        },
1228    }
1229}
1230
1231impl Drop for ConfigWatcherRuntime {
1232    fn drop(&mut self) {
1233        self.stop.store(true, Ordering::Relaxed);
1234        if let Some(task) = self.apply_task.take() {
1235            task.abort();
1236        }
1237        if let Some(task) = self.watcher_task.take() {
1238            let _ = task.join();
1239        }
1240    }
1241}
1242
1243async fn load_and_prepare_provider_candidate(
1244    store: &AtomicJsonStore<ProviderConfigs>,
1245    current_revision: u64,
1246    candidate_config: Config,
1247) -> Result<ProviderCandidate, ProviderCandidateError> {
1248    ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
1249        .map_err(|_| {
1250            ProviderCandidateError::invalid(
1251                "provider/MCP credential migration is pending; retaining last-known-good runtime",
1252            )
1253        })?;
1254    // Editors commonly implement save as delete/rename/create. Retry a missing
1255    // watched file briefly instead of treating the transient gap as a reset.
1256    for _ in 0..3 {
1257        if store.path().exists() {
1258            break;
1259        }
1260        tokio::time::sleep(Duration::from_millis(50)).await;
1261    }
1262    if !store.path().exists() {
1263        return Err(ProviderCandidateError::missing());
1264    }
1265    let stored = store
1266        .load_validated_for_reload_allowing_unversioned(
1267            current_revision,
1268            candidate_config.providers(),
1269            validate_provider_config,
1270        )
1271        .map_err(|_| {
1272            if store.path().exists() {
1273                ProviderCandidateError::invalid("provider section is invalid")
1274            } else {
1275                ProviderCandidateError::missing()
1276            }
1277        })?
1278        .ok_or_else(ProviderCandidateError::missing)?;
1279    if stored.recovered_from_backup {
1280        return Err(ProviderCandidateError::invalid(
1281            "primary provider section is invalid; retaining last-known-good runtime",
1282        ));
1283    }
1284    let unchanged = stored.revision == current_revision
1285        && serde_json::to_value(&stored.data).ok()
1286            == serde_json::to_value(candidate_config.providers()).ok();
1287    let mut candidate_config = candidate_config;
1288    *candidate_config.providers_mut() = stored.data;
1289    let (candidate_config, registry, provider) = prepare_provider_candidate(
1290        candidate_config,
1291        store
1292            .path()
1293            .parent()
1294            .unwrap_or_else(|| std::path::Path::new(".")),
1295    )
1296    .await?;
1297    Ok(ProviderCandidate {
1298        config: candidate_config,
1299        registry,
1300        provider,
1301        revision: stored.revision,
1302        normalized_external_revision: stored.normalized_external_revision,
1303        unchanged,
1304    })
1305}
1306
1307async fn prepare_provider_candidate(
1308    mut candidate_config: Config,
1309    data_dir: &std::path::Path,
1310) -> Result<(Config, bamboo_llm::ProviderRegistry, Arc<dyn LLMProvider>), ProviderCandidateError> {
1311    candidate_config.hydrate_provider_api_keys_from_encrypted();
1312    candidate_config
1313        .hydrate_provider_credentials_from_store(data_dir)
1314        .map_err(|_| ProviderCandidateError::invalid("provider credential is unavailable"))?;
1315    let candidate_registry =
1316        bamboo_llm::ProviderRegistry::from_config(&candidate_config, data_dir.to_path_buf())
1317            .await
1318            .map_err(|_| ProviderCandidateError::runtime())?;
1319    let candidate_provider = candidate_registry
1320        .get_default()
1321        .ok_or_else(ProviderCandidateError::runtime)?;
1322    Ok((candidate_config, candidate_registry, candidate_provider))
1323}
1324
1325struct ProviderCandidate {
1326    config: Config,
1327    registry: bamboo_llm::ProviderRegistry,
1328    provider: Arc<dyn LLMProvider>,
1329    revision: u64,
1330    normalized_external_revision: bool,
1331    unchanged: bool,
1332}
1333
1334async fn load_and_validate_mcp_candidate(
1335    store: &AtomicJsonStore<McpConfig>,
1336    current_revision: u64,
1337    mut candidate_config: Config,
1338    allow_startup_backup: bool,
1339) -> Result<McpCandidate, ProviderCandidateError> {
1340    ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
1341        .map_err(|_| {
1342            ProviderCandidateError::invalid(
1343                "provider/MCP credential migration is pending; retaining last-known-good runtime",
1344            )
1345        })?;
1346    for _ in 0..3 {
1347        if store.path().exists() {
1348            break;
1349        }
1350        tokio::time::sleep(Duration::from_millis(50)).await;
1351    }
1352    if !store.path().exists() {
1353        return Err(ProviderCandidateError::missing_section(
1354            "MCP section is missing",
1355        ));
1356    }
1357    let current_document = mcp_durable_comparison_document(&candidate_config.mcp);
1358    let stored = store
1359        .load_validated_for_reload(current_revision, &current_document, validate_mcp_config)
1360        .map_err(|_| ProviderCandidateError::invalid("MCP section is invalid"))?
1361        .ok_or_else(|| ProviderCandidateError::missing_section("MCP section is missing"))?;
1362    if stored.recovered_from_backup && !allow_startup_backup {
1363        return Err(ProviderCandidateError::invalid(
1364            "primary MCP section is invalid; retaining last-known-good runtime",
1365        ));
1366    }
1367    let unchanged = !allow_startup_backup
1368        && stored.revision == current_revision
1369        && serde_json::to_value(&stored.data).ok() == serde_json::to_value(&current_document).ok();
1370    candidate_config.mcp = stored.data;
1371    candidate_config.hydrate_mcp_secrets_from_encrypted();
1372    candidate_config
1373        .hydrate_mcp_credentials_from_store(
1374            store
1375                .path()
1376                .parent()
1377                .unwrap_or_else(|| std::path::Path::new(".")),
1378        )
1379        .map_err(|_| ProviderCandidateError::invalid("MCP credential is unavailable"))?;
1380    Ok(McpCandidate {
1381        config: candidate_config,
1382        revision: stored.revision,
1383        source_kind: if stored.recovered_from_backup {
1384            SectionSourceKind::Backup
1385        } else {
1386            SectionSourceKind::File
1387        },
1388        source_path: stored.source_path,
1389        normalized_external_revision: stored.normalized_external_revision,
1390        unchanged,
1391    })
1392}
1393
1394struct McpCandidate {
1395    config: Config,
1396    revision: u64,
1397    source_kind: SectionSourceKind,
1398    source_path: PathBuf,
1399    normalized_external_revision: bool,
1400    unchanged: bool,
1401}
1402
1403/// Project a hydrated runtime section back to its durable comparison shape.
1404/// Public compatibility serialization intentionally retains plaintext beside
1405/// ciphertext, while the sidecar stores only ciphertext for paired secrets.
1406fn mcp_durable_comparison_document(config: &McpConfig) -> McpConfig {
1407    let mut document = config.clone();
1408    for server in &mut document.servers {
1409        match &mut server.transport {
1410            TransportConfig::Stdio(config) => {
1411                config.env.retain(|name, _| {
1412                    !config.env_encrypted.contains_key(name)
1413                        && !config.env_credential_refs.contains_key(name)
1414                });
1415            }
1416            TransportConfig::Sse(config) => clear_paired_header_plaintext(&mut config.headers),
1417            TransportConfig::StreamableHttp(config) => {
1418                clear_paired_header_plaintext(&mut config.headers)
1419            }
1420        }
1421    }
1422    document
1423}
1424
1425fn clear_paired_header_plaintext(headers: &mut [bamboo_mcp::HeaderConfig]) {
1426    for header in headers {
1427        if header.value_encrypted.is_some() || header.credential_ref.is_some() {
1428            header.value.clear();
1429        }
1430    }
1431}
1432
1433fn validate_mcp_config(config: &McpConfig) -> Result<(), String> {
1434    let mut ids = std::collections::HashSet::new();
1435    for server in &config.servers {
1436        if server.id.trim().is_empty() {
1437            return Err("MCP server id cannot be empty".to_string());
1438        }
1439        if !ids.insert(server.id.as_str()) {
1440            return Err(format!("duplicate MCP server id '{}'", server.id));
1441        }
1442        if server.request_timeout_ms == 0 || server.healthcheck_interval_ms == 0 {
1443            return Err(format!(
1444                "MCP server '{}' timeouts must be non-zero",
1445                server.id
1446            ));
1447        }
1448        match &server.transport {
1449            TransportConfig::Stdio(stdio) if stdio.command.trim().is_empty() => {
1450                return Err(format!(
1451                    "MCP stdio server '{}' command cannot be empty",
1452                    server.id
1453                ));
1454            }
1455            TransportConfig::Sse(sse) if sse.url.trim().is_empty() => {
1456                return Err(format!(
1457                    "MCP SSE server '{}' URL cannot be empty",
1458                    server.id
1459                ));
1460            }
1461            TransportConfig::StreamableHttp(http) if http.url.trim().is_empty() => {
1462                return Err(format!(
1463                    "MCP HTTP server '{}' URL cannot be empty",
1464                    server.id
1465                ));
1466            }
1467            _ => {}
1468        }
1469        match &server.transport {
1470            TransportConfig::Stdio(stdio) => {
1471                if !stdio.env_encrypted.is_empty()
1472                    || stdio.env.iter().any(|(name, value)| {
1473                        !value.is_empty() && !stdio.env_credential_refs.contains_key(name)
1474                    })
1475                {
1476                    return Err(format!(
1477                        "MCP server '{}' contains a secret outside the credential store",
1478                        server.id
1479                    ));
1480                }
1481                for raw in stdio.env_credential_refs.values() {
1482                    bamboo_config::CredentialRef::parse(raw.clone())
1483                        .map_err(|_| "MCP credential reference is invalid".to_string())?;
1484                }
1485            }
1486            TransportConfig::Sse(config) => validate_header_refs(&server.id, &config.headers)?,
1487            TransportConfig::StreamableHttp(config) => {
1488                validate_header_refs(&server.id, &config.headers)?
1489            }
1490        }
1491    }
1492    Ok(())
1493}
1494
1495fn validate_provider_config(providers: &ProviderConfigs) -> Result<(), String> {
1496    macro_rules! validate {
1497        ($field:ident) => {
1498            if let Some(provider) = &providers.$field {
1499                if provider.api_key_encrypted.is_some()
1500                    || (!provider.api_key.trim().is_empty()
1501                        && !provider.api_key_from_env
1502                        && provider.credential_ref.is_none())
1503                {
1504                    return Err("provider secret is outside the credential store".to_string());
1505                }
1506            }
1507        };
1508    }
1509    validate!(openai);
1510    validate!(anthropic);
1511    validate!(gemini);
1512    if let Some(provider) = &providers.bodhi {
1513        if provider.api_key_encrypted.is_some()
1514            || (!provider.api_key.trim().is_empty() && provider.credential_ref.is_none())
1515        {
1516            return Err("provider secret is outside the credential store".to_string());
1517        }
1518    }
1519    Ok(())
1520}
1521
1522fn validate_header_refs(
1523    server_id: &str,
1524    headers: &[bamboo_mcp::HeaderConfig],
1525) -> Result<(), String> {
1526    for header in headers {
1527        if header.value_encrypted.is_some()
1528            || (!header.value.is_empty() && header.credential_ref.is_none())
1529        {
1530            return Err(format!(
1531                "MCP server '{server_id}' contains a secret outside the credential store"
1532            ));
1533        }
1534        if let Some(raw) = &header.credential_ref {
1535            bamboo_config::CredentialRef::parse(raw.clone())
1536                .map_err(|_| "MCP credential reference is invalid".to_string())?;
1537        }
1538    }
1539    Ok(())
1540}
1541
1542enum ProviderCandidateErrorKind {
1543    Missing,
1544    InvalidDocument,
1545    Runtime,
1546}
1547
1548struct ProviderCandidateError {
1549    kind: ProviderCandidateErrorKind,
1550    message: String,
1551}
1552
1553impl ProviderCandidateError {
1554    fn missing() -> Self {
1555        Self {
1556            kind: ProviderCandidateErrorKind::Missing,
1557            message: "provider section is missing".to_string(),
1558        }
1559    }
1560
1561    fn invalid(message: &str) -> Self {
1562        Self {
1563            kind: ProviderCandidateErrorKind::InvalidDocument,
1564            message: message.to_string(),
1565        }
1566    }
1567
1568    fn missing_section(message: &str) -> Self {
1569        Self {
1570            kind: ProviderCandidateErrorKind::Missing,
1571            message: message.to_string(),
1572        }
1573    }
1574
1575    fn runtime() -> Self {
1576        Self {
1577            kind: ProviderCandidateErrorKind::Runtime,
1578            message: "provider runtime initialization failed".to_string(),
1579        }
1580    }
1581}
1582
1583fn candidate_error_status(kind: &ProviderCandidateErrorKind) -> SectionStatus {
1584    match kind {
1585        ProviderCandidateErrorKind::Missing => SectionStatus::Missing,
1586        ProviderCandidateErrorKind::InvalidDocument => SectionStatus::Invalid,
1587        ProviderCandidateErrorKind::Runtime => SectionStatus::Degraded,
1588    }
1589}
1590
1591fn update_live_health(
1592    health: &std::sync::RwLock<ConfigLiveHealth>,
1593    status: SectionStatus,
1594    last_error: Option<String>,
1595    advance_revision: bool,
1596    source: Option<(PathBuf, SectionSourceKind)>,
1597) -> u64 {
1598    let mut health = health
1599        .write()
1600        .unwrap_or_else(|poisoned| poisoned.into_inner());
1601    if advance_revision {
1602        health.revision = health.revision.saturating_add(1);
1603    }
1604    health.loaded_at = Utc::now();
1605    health.status = status;
1606    health.last_error = last_error;
1607    if let Some((source_path, source_kind)) = source {
1608        health.source_path = source_path;
1609        health.source_kind = source_kind;
1610    }
1611    health.revision
1612}
1613
1614fn set_live_health_revision(
1615    health: &std::sync::RwLock<ConfigLiveHealth>,
1616    revision: u64,
1617    source: Option<(PathBuf, SectionSourceKind)>,
1618) -> u64 {
1619    let mut health = health
1620        .write()
1621        .unwrap_or_else(|poisoned| poisoned.into_inner());
1622    health.revision = revision;
1623    health.loaded_at = Utc::now();
1624    health.status = SectionStatus::Healthy;
1625    health.last_error = None;
1626    if let Some((source_path, source_kind)) = source {
1627        health.source_path = source_path;
1628        health.source_kind = source_kind;
1629    }
1630    revision
1631}
1632
1633#[derive(Debug)]
1634pub(crate) enum ConfigSectionMutationError {
1635    Store(ConfigStoreError),
1636    Invalid(String),
1637    Runtime(String),
1638}
1639
1640pub(crate) enum CredentialBackedResetCommit {
1641    Section(bamboo_config::SectionEnvelope<Value>),
1642    Cluster(Box<bamboo_server_tools::FabricCommitSnapshot>),
1643}
1644
1645impl AppState {
1646    #[cfg(test)]
1647    pub(crate) fn stop_config_watcher_for_test(&mut self) {
1648        self.config_watcher.stop.store(true, Ordering::Relaxed);
1649        if let Some(task) = self.config_watcher.apply_task.take() {
1650            task.abort();
1651        }
1652        if let Some(task) = self.config_watcher.watcher_task.take() {
1653            let _ = task.join();
1654        }
1655    }
1656
1657    #[cfg(test)]
1658    pub(crate) async fn reload_ordinary_section_for_test(&self, id: SectionId) {
1659        let _io = self.config_io_lock.lock().await;
1660        let facade = self
1661            .config_facade
1662            .as_ref()
1663            .expect("test ordinary reload requires the modular facade");
1664        reload_and_apply_ordinary_sections(
1665            &self.app_data_dir,
1666            &self.config,
1667            facade,
1668            &self.account_sink,
1669            std::iter::once(id),
1670        )
1671        .await;
1672    }
1673
1674    /// Read one exact durable credential-backed section and its secret-free
1675    /// credential status generation. The process facade may lag another
1676    /// process, so GET handlers must not assemble these authorities
1677    /// separately.
1678    pub(crate) async fn read_exact_credential_section(
1679        &self,
1680        section: SectionId,
1681    ) -> Result<ExactCredentialSectionSnapshot, AppError> {
1682        let _io = self.config_io_lock.lock().await;
1683        let data_dir = self.app_data_dir.clone();
1684        let exact = tokio::task::spawn_blocking(move || {
1685            bamboo_config::read_exact_credential_section_snapshot(data_dir, section, None)
1686        })
1687        .await
1688        .map_err(|error| {
1689            AppError::InternalError(anyhow::anyhow!(
1690                "{} exact read snapshot task failed: {error}",
1691                section.descriptor().name
1692            ))
1693        })?
1694        .map_err(map_exact_credential_store_error)?;
1695        let envelope = exact.section.clone();
1696        let mut config = Config::default();
1697        let metadata = exact.install_into(&mut config);
1698        Ok(ExactCredentialSectionSnapshot {
1699            config,
1700            section: envelope,
1701            metadata,
1702        })
1703    }
1704
1705    /// Commit a single ordinary typed section with CAS, then publish exactly
1706    /// that section into the process-owned effective snapshot. Credential
1707    /// bindings are server-owned and cannot be forged or detached here.
1708    pub(crate) async fn put_ordinary_section(
1709        &self,
1710        id: SectionId,
1711        expected_revision: u64,
1712        candidate: Value,
1713    ) -> Result<bamboo_config::SectionEnvelope<Value>, ConfigSectionMutationError> {
1714        if matches!(
1715            id,
1716            SectionId::Providers
1717                | SectionId::Mcp
1718                | SectionId::Credentials
1719                | SectionId::ClusterFabric
1720        ) {
1721            return Err(ConfigSectionMutationError::Invalid(
1722                "this section requires its dedicated endpoint".to_string(),
1723            ));
1724        }
1725        let _io = self.config_io_lock.lock().await;
1726        ensure_provider_mcp_migration_ready(&self.app_data_dir)
1727            .map_err(ConfigSectionMutationError::Store)?;
1728        let facade = self.config_facade.as_ref().ok_or_else(|| {
1729            ConfigSectionMutationError::Invalid(
1730                "typed section writes require the modular configuration facade".to_string(),
1731            )
1732        })?;
1733        let current = if id == SectionId::Core {
1734            let data_dir = self.app_data_dir.clone();
1735            let exact = tokio::task::spawn_blocking(move || {
1736                bamboo_config::read_exact_credential_section_snapshot(
1737                    data_dir,
1738                    SectionId::Core,
1739                    Some(expected_revision),
1740                )
1741            })
1742            .await
1743            .map_err(|error| {
1744                ConfigSectionMutationError::Runtime(format!(
1745                    "Core exact inventory snapshot task failed: {error}"
1746                ))
1747            })?
1748            .map_err(ConfigSectionMutationError::Store)?;
1749            if let Some(reference) = exact
1750                .section
1751                .data
1752                .get("proxy_auth_credential_ref")
1753                .and_then(Value::as_str)
1754            {
1755                let configured = exact
1756                    .credential_statuses
1757                    .iter()
1758                    .any(|status| status.credential_ref.as_str() == reference && status.configured);
1759                if !configured {
1760                    return Err(ConfigSectionMutationError::Invalid(
1761                        "the active Core proxy credential is invalid; explicitly replace or clear it through the proxy-auth API"
1762                            .to_string(),
1763                    ));
1764                }
1765            }
1766            exact.section
1767        } else {
1768            facade
1769                .registry()
1770                .envelope_value(id)
1771                .map_err(ConfigSectionMutationError::Store)?
1772        };
1773        if credential_reference_inventory(&current.data)
1774            != credential_reference_inventory(&candidate)
1775        {
1776            return Err(ConfigSectionMutationError::Invalid(
1777                "credential references are server-managed; use the credential or domain API"
1778                    .to_string(),
1779            ));
1780        }
1781
1782        let (event, committed) = if id == SectionId::Core {
1783            // Exact Core GETs may be ahead of this process's watcher. Keep the
1784            // shared credential transaction lock from durable-base validation
1785            // through the content-aware Core CAS and jump directly to the
1786            // committed revision; never consume or publish the intermediate
1787            // generation before its runtime has been installed.
1788            bamboo_config::commit_core_metadata_from_durable_base(
1789                &self.app_data_dir,
1790                facade,
1791                expected_revision,
1792                candidate,
1793            )
1794        } else {
1795            facade
1796                .registry()
1797                .commit_value_with_envelope(id, expected_revision, candidate)
1798        }
1799        .map_err(ConfigSectionMutationError::Store)?;
1800        let materialized = materialize_facade_effective_config(facade, &self.app_data_dir);
1801        if materialized.failures.contains(&id) {
1802            let message =
1803                "configuration runtime hydration failed; retaining last-known-good runtime"
1804                    .to_string();
1805            if let Some(invalid) = facade.registry().mark_runtime_degraded(id, message.clone()) {
1806                publish_registry_event(&self.account_sink, &invalid);
1807            }
1808            return Err(ConfigSectionMutationError::Runtime(message));
1809        }
1810
1811        let mut live = self.config.read().await.clone();
1812        let enforcement_newly_off = id == SectionId::ToolsSkills
1813            && !live.plugin_trust.enforcement_is_off()
1814            && materialized.config.plugin_trust.enforcement_is_off();
1815        apply_runtime_section(id, &materialized.config, &mut live);
1816        if id == SectionId::Env {
1817            live.publish_env_vars();
1818        }
1819        *self.config.write().await = live;
1820        if enforcement_newly_off {
1821            warn_plugin_trust_enforcement_off();
1822        }
1823        publish_registry_event(&self.account_sink, &event);
1824        Ok(committed)
1825    }
1826
1827    /// Validate and stage a provider runtime before the first durable CAS
1828    /// write, then publish config/runtime/health/event under `config_io_lock`.
1829    pub(crate) async fn put_provider_section(
1830        &self,
1831        expected_revision: u64,
1832        mut providers: ProviderConfigs,
1833    ) -> Result<u64, ConfigSectionMutationError> {
1834        let _io = self.config_io_lock.lock().await;
1835        ensure_provider_mcp_migration_ready(&self.app_data_dir)
1836            .map_err(ConfigSectionMutationError::Store)?;
1837        let current = self.config.read().await.clone();
1838        retain_provider_credentials(current.providers(), &mut providers);
1839        let mut candidate = current;
1840        *candidate.providers_mut() = providers.clone();
1841        let (candidate, registry, provider) =
1842            match prepare_provider_candidate(candidate, &self.app_data_dir).await {
1843                Ok(prepared) => prepared,
1844                Err(_) => {
1845                    let message =
1846                        "provider runtime initialization failed; retaining last-known-good runtime"
1847                            .to_string();
1848                    publish_section_failure(
1849                        &self.config_live_health,
1850                        &self.account_sink,
1851                        "providers",
1852                        SectionStatus::Degraded,
1853                        message.clone(),
1854                    );
1855                    return Err(ConfigSectionMutationError::Runtime(message));
1856                }
1857            };
1858
1859        let durable_providers = provider_durable_document(&providers)?;
1860        // Acquire every async publication guard before crossing the durable
1861        // boundary. Once commit succeeds, cancellation cannot strand the file
1862        // ahead of the live config/provider snapshots.
1863        let mut live_config = self.config.write().await;
1864        let mut live_provider = self.provider.write().await;
1865        let (revision, source_path) = if let Some(facade) = self.config_facade.as_ref() {
1866            let mut section = facade.registry().providers.snapshot().data.as_ref().clone();
1867            section.providers = durable_providers;
1868            let event = facade
1869                .registry()
1870                .providers
1871                .commit(expected_revision, section)
1872                .map_err(ConfigSectionMutationError::Store)?;
1873            let ConfigSectionEvent::Changed { revision, .. } = event else {
1874                unreachable!("a successful section commit is changed")
1875            };
1876            (
1877                revision,
1878                facade.registry().providers.snapshot().source_path.clone(),
1879            )
1880        } else {
1881            let store = AtomicJsonStore::new(self.app_data_dir.join("providers.json"), 1);
1882            let revision = store
1883                .commit_allowing_unversioned(
1884                    expected_revision,
1885                    durable_providers,
1886                    validate_provider_config,
1887                )
1888                .map_err(ConfigSectionMutationError::Store)?;
1889            (revision, store.path().to_path_buf())
1890        };
1891
1892        candidate.publish_env_vars();
1893        *live_config = candidate;
1894        self.provider_registry.replace_with(registry);
1895        *live_provider = provider;
1896        publish_section_success(
1897            &self.config_live_health,
1898            &self.account_sink,
1899            "providers",
1900            source_path,
1901            section_is_unhealthy(&self.config_live_health),
1902            Some(revision),
1903        );
1904        Ok(revision)
1905    }
1906
1907    /// Replace the complete provider-owned settings domain under the typed
1908    /// provider section revision. Provider metadata and explicitly touched
1909    /// built-in/instance credentials share one recoverable exact transaction;
1910    /// runtime construction is staged before the durable CAS boundary.
1911    pub(crate) async fn put_provider_settings<F>(
1912        &self,
1913        expected_revision: u64,
1914        update: F,
1915    ) -> Result<u64, ConfigSectionMutationError>
1916    where
1917        F: FnOnce(
1918                &Config,
1919                &mut Config,
1920            )
1921                -> Result<(BTreeSet<String>, BTreeSet<String>), ConfigSectionMutationError>
1922            + Send
1923            + 'static,
1924    {
1925        let config_io_lock = self.config_io_lock.clone();
1926        let config = self.config.clone();
1927        let app_data_dir = self.app_data_dir.clone();
1928        let config_facade = self.config_facade.clone();
1929        let account_sink = self.account_sink.clone();
1930        let provider_registry = self.provider_registry.clone();
1931        let provider = self.provider.clone();
1932        let config_live_health = self.config_live_health.clone();
1933        let transaction = tokio::spawn(async move {
1934            let _io = config_io_lock.lock().await;
1935            ensure_provider_mcp_migration_ready(&app_data_dir)
1936                .map_err(ConfigSectionMutationError::Store)?;
1937            let facade = config_facade.as_ref().ok_or_else(|| {
1938                ConfigSectionMutationError::Invalid(
1939                    "provider settings require the modular configuration facade".to_string(),
1940                )
1941            })?;
1942            let current = config.read().await.clone();
1943            let mut candidate = current.clone();
1944            let (provider_intents, provider_instance_intents) = update(&current, &mut candidate)?;
1945
1946            let (candidate, registry, candidate_provider) =
1947                match prepare_provider_candidate(candidate, &app_data_dir).await {
1948                    Ok(prepared) => prepared,
1949                    Err(_) => {
1950                        let message =
1951                        "provider runtime initialization failed; retaining last-known-good runtime"
1952                            .to_string();
1953                        publish_section_failure(
1954                            &config_live_health,
1955                            &account_sink,
1956                            "providers",
1957                            SectionStatus::Degraded,
1958                            message.clone(),
1959                        );
1960                        return Err(ConfigSectionMutationError::Runtime(message));
1961                    }
1962                };
1963
1964            // Acquire publication guards before the durable boundary. The
1965            // detached task owns them until config and provider runtime are
1966            // published, so request cancellation cannot strand disk ahead of
1967            // process state.
1968            let mut live_config = config.write().await;
1969            let mut live_provider = provider.write().await;
1970            let transaction_dir = app_data_dir.clone();
1971            let commit_facade = facade.clone();
1972            let (mut committed, commit) = tokio::task::spawn_blocking(move || {
1973                let mut durable_candidate = candidate;
1974                let commit =
1975                    bamboo_config::persist_provider_credential_transaction_at_revision_with_adoption(
1976                    &transaction_dir,
1977                    &mut durable_candidate,
1978                    &provider_intents,
1979                    &provider_instance_intents,
1980                    expected_revision,
1981                    commit_facade.as_ref(),
1982                )?;
1983                Ok::<_, ConfigStoreError>((durable_candidate, commit))
1984            })
1985            .await
1986            .map_err(|error| {
1987                ConfigSectionMutationError::Runtime(format!(
1988                    "provider settings transaction task failed: {error}"
1989                ))
1990            })?
1991            .map_err(ConfigSectionMutationError::Store)?;
1992
1993            let installed = install_credential_section_commit(commit, &mut committed)
1994                .map_err(ConfigSectionMutationError::Store)?;
1995            committed.publish_env_vars();
1996            *live_config = committed;
1997            provider_registry.replace_with(registry);
1998            *live_provider = candidate_provider;
1999            publish_exact_facade_events(&account_sink, &installed.events)
2000                .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
2001            let provider_snapshot = facade.registry().providers.snapshot();
2002            set_live_health_revision(
2003                &config_live_health,
2004                provider_snapshot.revision,
2005                Some((
2006                    provider_snapshot.source_path.clone(),
2007                    SectionSourceKind::File,
2008                )),
2009            );
2010            Ok(provider_snapshot.revision)
2011        });
2012        transaction.await.map_err(|error| {
2013            ConfigSectionMutationError::Runtime(format!(
2014                "provider settings transaction task failed: {error}"
2015            ))
2016        })?
2017    }
2018
2019    /// Reset the complete provider section and all provider-owned credentials
2020    /// in one recoverable exact transaction guarded by the typed section
2021    /// revision. Runtime publication remains last-known-good when the default
2022    /// provider cannot be initialized without credentials.
2023    pub(crate) async fn reset_provider_section(
2024        &self,
2025        expected_revision: u64,
2026    ) -> Result<u64, ConfigSectionMutationError> {
2027        let config_io_lock = self.config_io_lock.clone();
2028        let config = self.config.clone();
2029        let app_data_dir = self.app_data_dir.clone();
2030        let config_facade = self.config_facade.clone();
2031        let account_sink = self.account_sink.clone();
2032        let provider_registry = self.provider_registry.clone();
2033        let provider = self.provider.clone();
2034        let config_live_health = self.config_live_health.clone();
2035        let transaction = tokio::spawn(async move {
2036            let _io = config_io_lock.lock().await;
2037            ensure_provider_mcp_migration_ready(&app_data_dir)
2038                .map_err(ConfigSectionMutationError::Store)?;
2039            let facade = config_facade.as_ref().ok_or_else(|| {
2040                ConfigSectionMutationError::Invalid(
2041                    "provider reset requires the modular configuration facade".to_string(),
2042                )
2043            })?;
2044            let current = config.read().await.clone();
2045            let provider_intents = BTreeSet::from([
2046                "openai".to_string(),
2047                "anthropic".to_string(),
2048                "gemini".to_string(),
2049                "bodhi".to_string(),
2050            ]);
2051            let provider_instance_intents = current.provider_instances.keys().cloned().collect();
2052            let mut candidate = current;
2053            apply_runtime_section(SectionId::Providers, &Config::default(), &mut candidate);
2054
2055            let transaction_dir = app_data_dir.clone();
2056            let commit_facade = facade.clone();
2057            let (mut candidate, commit) = tokio::task::spawn_blocking(move || {
2058                let commit =
2059                    bamboo_config::persist_provider_reset_credential_transaction_at_revision_with_adoption(
2060                    &transaction_dir,
2061                    &mut candidate,
2062                    &provider_intents,
2063                    &provider_instance_intents,
2064                    expected_revision,
2065                    commit_facade.as_ref(),
2066                )?;
2067                Ok::<_, ConfigStoreError>((candidate, commit))
2068            })
2069            .await
2070            .map_err(|error| {
2071                ConfigSectionMutationError::Runtime(format!(
2072                    "provider reset transaction task failed: {error}"
2073                ))
2074            })?
2075            .map_err(ConfigSectionMutationError::Store)?;
2076
2077            let installed = install_credential_section_commit(commit, &mut candidate)
2078                .map_err(ConfigSectionMutationError::Store)?;
2079            *config.write().await = candidate.clone();
2080            let provider_snapshot = facade.registry().providers.snapshot();
2081            set_live_health_revision(
2082                &config_live_health,
2083                provider_snapshot.revision,
2084                Some((
2085                    provider_snapshot.source_path.clone(),
2086                    SectionSourceKind::File,
2087                )),
2088            );
2089            publish_exact_facade_events(&account_sink, &installed.events)
2090                .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
2091
2092            let runtime_failure = match bamboo_llm::ProviderRegistry::from_config(
2093                &candidate,
2094                app_data_dir,
2095            )
2096            .await
2097            {
2098                Ok(registry) => {
2099                    if let Some(candidate_provider) = registry.get_default() {
2100                        provider_registry.replace_with(registry);
2101                        *provider.write().await = candidate_provider;
2102                        None
2103                    } else {
2104                        Some(
2105                            "provider reset committed; default provider is not initialized"
2106                                .to_string(),
2107                        )
2108                    }
2109                }
2110                Err(error) => {
2111                    tracing::warn!(error = %error, "provider reset committed but runtime initialization failed");
2112                    Some("provider reset committed; retaining last-known-good runtime".to_string())
2113                }
2114            };
2115            if let Some(message) = runtime_failure {
2116                publish_section_failure(
2117                    &config_live_health,
2118                    &account_sink,
2119                    "providers",
2120                    SectionStatus::Degraded,
2121                    message,
2122                );
2123            }
2124            Ok(provider_snapshot.revision)
2125        });
2126        transaction.await.map_err(|error| {
2127            ConfigSectionMutationError::Runtime(format!(
2128                "provider reset transaction task failed: {error}"
2129            ))
2130        })?
2131    }
2132
2133    /// Stage MCP connection/initialization/tool discovery, perform the CAS at
2134    /// the manager's pre-publication boundary, then publish the config snapshot
2135    /// before the prepared runtimes and finally emit one section event.
2136    pub(crate) async fn put_mcp_section(
2137        &self,
2138        expected_revision: u64,
2139        mut candidate: McpConfig,
2140    ) -> Result<u64, ConfigSectionMutationError> {
2141        let _io = self.config_io_lock.lock().await;
2142        ensure_provider_mcp_migration_ready(&self.app_data_dir)
2143            .map_err(ConfigSectionMutationError::Store)?;
2144        retain_mcp_credentials(
2145            &self.config.read().await.mcp,
2146            &mut candidate,
2147            &BTreeSet::new(),
2148        );
2149        validate_mcp_config(&candidate).map_err(ConfigSectionMutationError::Invalid)?;
2150        let mut hydration_config = Config::default();
2151        hydration_config.mcp = candidate;
2152        hydration_config
2153            .hydrate_mcp_credentials_from_store(&self.app_data_dir)
2154            .map_err(|_| {
2155                ConfigSectionMutationError::Invalid(
2156                    "referenced MCP credential is unavailable".to_string(),
2157                )
2158            })?;
2159        let candidate = hydration_config.mcp.clone();
2160        let mut revision = None;
2161        let mut store_error = None;
2162        let durable_candidate = credential_ref_mcp_document(&candidate)?;
2163        let mut next_config = candidate.clone();
2164        retain_mcp_credential_refs(&durable_candidate, &mut next_config);
2165        let result = self
2166            .mcp_manager
2167            .reconcile_from_config_transactional_after(&candidate, || async {
2168                // Stage may await freely, but acquire the snapshot guard before
2169                // the durable boundary. Commit + snapshot publication below is
2170                // then one cancellation-free synchronous critical section.
2171                let mut live_config = self.config.write().await;
2172                let commit = if let Some(facade) = self.config_facade.as_ref() {
2173                    facade
2174                        .registry()
2175                        .mcp
2176                        .commit(expected_revision, McpSection(durable_candidate))
2177                        .map(|event| match event {
2178                            ConfigSectionEvent::Changed { revision, .. } => revision,
2179                            _ => unreachable!("a successful section commit is changed"),
2180                        })
2181                } else {
2182                    AtomicJsonStore::new(self.app_data_dir.join("mcp.json"), 1).commit(
2183                        expected_revision,
2184                        durable_candidate,
2185                        validate_mcp_config,
2186                    )
2187                };
2188                match commit {
2189                    Ok(committed) => {
2190                        live_config.mcp = next_config;
2191                        revision = Some(committed);
2192                        Ok(())
2193                    }
2194                    Err(error) => {
2195                        store_error = Some(error);
2196                        Err(bamboo_mcp::McpError::InvalidConfig(
2197                            "MCP section durable commit failed".to_string(),
2198                        ))
2199                    }
2200                }
2201            })
2202            .await;
2203        if let Some(error) = store_error {
2204            return Err(ConfigSectionMutationError::Store(error));
2205        }
2206        if result.is_err() {
2207            let message =
2208                "MCP runtime initialization failed; retaining last-known-good runtime".to_string();
2209            publish_section_failure(
2210                &self.mcp_config_live_health,
2211                &self.account_sink,
2212                "mcp",
2213                SectionStatus::Degraded,
2214                message.clone(),
2215            );
2216            return Err(ConfigSectionMutationError::Runtime(message));
2217        }
2218        let revision = revision.expect("successful MCP reconcile commits a revision");
2219        publish_section_success(
2220            &self.mcp_config_live_health,
2221            &self.account_sink,
2222            "mcp",
2223            self.app_data_dir.join("mcp.json"),
2224            section_is_unhealthy(&self.mcp_config_live_health),
2225            Some(revision),
2226        );
2227        Ok(revision)
2228    }
2229
2230    /// Replace editable MCP metadata and explicitly touched credentials under
2231    /// one section CAS. Runtime construction is staged first; credential and
2232    /// MCP documents cross the durable boundary together.
2233    pub(crate) async fn put_mcp_settings(
2234        &self,
2235        expected_revision: u64,
2236        candidate: McpConfig,
2237        credential_intents: BTreeSet<bamboo_config::CredentialRef>,
2238    ) -> Result<u64, ConfigSectionMutationError> {
2239        if credential_intents.is_empty() {
2240            return self.put_mcp_section(expected_revision, candidate).await;
2241        }
2242
2243        let config_io_lock = self.config_io_lock.clone();
2244        let config = self.config.clone();
2245        let app_data_dir = self.app_data_dir.clone();
2246        let config_facade = self.config_facade.clone();
2247        let account_sink = self.account_sink.clone();
2248        let mcp_manager = self.mcp_manager.clone();
2249        let mcp_config_live_health = self.mcp_config_live_health.clone();
2250        let transaction = tokio::spawn(async move {
2251            let _io = config_io_lock.lock().await;
2252            ensure_provider_mcp_migration_ready(&app_data_dir)
2253                .map_err(ConfigSectionMutationError::Store)?;
2254            let facade = config_facade.as_ref().ok_or_else(|| {
2255                ConfigSectionMutationError::Invalid(
2256                    "MCP settings require the modular configuration facade".to_string(),
2257                )
2258            })?;
2259            let current = config.read().await.clone();
2260            let mut runtime_candidate = candidate;
2261            materialize_mcp_touched_replacements(&mut runtime_candidate, &credential_intents)
2262                .map_err(ConfigSectionMutationError::Invalid)?;
2263            retain_mcp_credentials(&current.mcp, &mut runtime_candidate, &credential_intents);
2264            validate_mcp_config(&runtime_candidate).map_err(ConfigSectionMutationError::Invalid)?;
2265
2266            let mut transaction_error = None;
2267            let mut commit_events = Vec::new();
2268            let transaction_dir = app_data_dir.clone();
2269            let commit_facade = facade.clone();
2270            let mut durable_candidate = current;
2271            durable_candidate.mcp = runtime_candidate.clone();
2272            let result = mcp_manager
2273                .reconcile_from_config_transactional_after(&runtime_candidate, || async {
2274                    let mut live_config = config.write().await;
2275                    let commit = tokio::task::spawn_blocking(move || {
2276                        let commit =
2277                            bamboo_config::persist_mcp_credential_transaction_at_revision_with_adoption(
2278                            &transaction_dir,
2279                            &mut durable_candidate,
2280                            &credential_intents,
2281                            expected_revision,
2282                            commit_facade.as_ref(),
2283                        )?;
2284                        Ok::<_, ConfigStoreError>((durable_candidate, commit))
2285                    })
2286                    .await;
2287                    match commit {
2288                        Ok(Ok((mut committed, commit))) => {
2289                            let installed =
2290                                match install_credential_section_commit(commit, &mut committed) {
2291                                    Ok(installed) => installed,
2292                                    Err(error) => {
2293                                        transaction_error =
2294                                            Some(ConfigSectionMutationError::Store(error));
2295                                        return Err(bamboo_mcp::McpError::InvalidConfig(
2296                                            "MCP settings process adoption failed".to_string(),
2297                                        ));
2298                                    }
2299                                };
2300                            *live_config = committed;
2301                            commit_events = installed.events;
2302                            Ok(())
2303                        }
2304                        Ok(Err(error)) => {
2305                            transaction_error = Some(ConfigSectionMutationError::Store(error));
2306                            Err(bamboo_mcp::McpError::InvalidConfig(
2307                                "MCP settings durable transaction failed".to_string(),
2308                            ))
2309                        }
2310                        Err(error) => {
2311                            transaction_error = Some(ConfigSectionMutationError::Runtime(format!(
2312                                "MCP settings transaction task failed: {error}"
2313                            )));
2314                            Err(bamboo_mcp::McpError::InvalidConfig(
2315                                "MCP settings durable transaction failed".to_string(),
2316                            ))
2317                        }
2318                    }
2319                })
2320                .await;
2321            if let Some(error) = transaction_error {
2322                return Err(error);
2323            }
2324            if result.is_err() {
2325                let message =
2326                    "MCP runtime initialization failed; retaining last-known-good runtime"
2327                        .to_string();
2328                publish_section_failure(
2329                    &mcp_config_live_health,
2330                    &account_sink,
2331                    "mcp",
2332                    SectionStatus::Degraded,
2333                    message.clone(),
2334                );
2335                return Err(ConfigSectionMutationError::Runtime(message));
2336            }
2337
2338            publish_exact_facade_events(&account_sink, &commit_events)
2339                .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
2340            let snapshot = facade.registry().mcp.snapshot();
2341            set_live_health_revision(
2342                &mcp_config_live_health,
2343                snapshot.revision,
2344                Some((snapshot.source_path.clone(), SectionSourceKind::File)),
2345            );
2346            Ok(snapshot.revision)
2347        });
2348        transaction.await.map_err(|error| {
2349            ConfigSectionMutationError::Runtime(format!(
2350                "MCP settings transaction task failed: {error}"
2351            ))
2352        })?
2353    }
2354
2355    /// Reset MCP metadata and its owned credentials at the runtime manager's
2356    /// pre-publication boundary, preserving the same durable-before-live
2357    /// ordering as a normal typed MCP write.
2358    pub(crate) async fn reset_mcp_section(
2359        &self,
2360        expected_revision: u64,
2361    ) -> Result<u64, ConfigSectionMutationError> {
2362        let _io = self.config_io_lock.lock().await;
2363        ensure_provider_mcp_migration_ready(&self.app_data_dir)
2364            .map_err(ConfigSectionMutationError::Store)?;
2365        let facade = self.config_facade.as_ref().ok_or_else(|| {
2366            ConfigSectionMutationError::Invalid(
2367                "MCP reset requires the modular configuration facade".to_string(),
2368            )
2369        })?;
2370        let candidate_mcp = McpConfig::default();
2371        let mut candidate_config = self.config.read().await.clone();
2372        candidate_config.mcp = candidate_mcp.clone();
2373        let mut committed = false;
2374        let mut commit_events = Vec::new();
2375        let mut store_error = None;
2376        let data_dir = self.app_data_dir.clone();
2377        let result = self
2378            .mcp_manager
2379            .reconcile_from_config_transactional_after(&candidate_mcp, || async {
2380                let mut live_config = self.config.write().await;
2381                match bamboo_config::persist_mcp_reset_credential_transaction_at_revision_with_adoption(
2382                    &data_dir,
2383                    &mut candidate_config,
2384                    expected_revision,
2385                    facade.as_ref(),
2386                ) {
2387                    Ok(commit) => {
2388                        match install_credential_section_commit(commit, &mut candidate_config) {
2389                            Ok(installed) => {
2390                                *live_config = candidate_config.clone();
2391                                commit_events = installed.events;
2392                                committed = true;
2393                            }
2394                            Err(error) => {
2395                                store_error = Some(error);
2396                                return Err(bamboo_mcp::McpError::InvalidConfig(
2397                                    "MCP reset process adoption failed".to_string(),
2398                                ));
2399                            }
2400                        }
2401                        Ok(())
2402                    }
2403                    Err(error) => {
2404                        store_error = Some(error);
2405                        Err(bamboo_mcp::McpError::InvalidConfig(
2406                            "MCP reset durable commit failed".to_string(),
2407                        ))
2408                    }
2409                }
2410            })
2411            .await;
2412        if let Some(error) = store_error {
2413            return Err(ConfigSectionMutationError::Store(error));
2414        }
2415        if result.is_err() || !committed {
2416            let message =
2417                "MCP reset runtime initialization failed; retaining last-known-good runtime"
2418                    .to_string();
2419            publish_section_failure(
2420                &self.mcp_config_live_health,
2421                &self.account_sink,
2422                "mcp",
2423                SectionStatus::Degraded,
2424                message.clone(),
2425            );
2426            return Err(ConfigSectionMutationError::Runtime(message));
2427        }
2428        publish_exact_facade_events(&self.account_sink, &commit_events)
2429            .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
2430        let revision = facade.registry().mcp.snapshot().revision;
2431        publish_section_success(
2432            &self.mcp_config_live_health,
2433            &self.account_sink,
2434            "mcp",
2435            self.app_data_dir.join("mcp.json"),
2436            section_is_unhealthy(&self.mcp_config_live_health),
2437            Some(revision),
2438        );
2439        Ok(revision)
2440    }
2441
2442    /// Reset a credential-backed non-provider section using the typed section
2443    /// revision as the client CAS authority. The exact transaction rebases on
2444    /// the latest unrelated credential document while clearing only references
2445    /// owned by this section.
2446    pub(crate) async fn reset_credential_backed_section(
2447        &self,
2448        id: SectionId,
2449        expected_revision: u64,
2450    ) -> Result<CredentialBackedResetCommit, ConfigSectionMutationError> {
2451        if !matches!(
2452            id,
2453            SectionId::Core
2454                | SectionId::Notifications
2455                | SectionId::Connect
2456                | SectionId::Env
2457                | SectionId::ClusterFabric
2458                | SectionId::AccessControl
2459        ) {
2460            return Err(ConfigSectionMutationError::Invalid(
2461                "section is not a credential-backed reset domain".to_string(),
2462            ));
2463        }
2464        let config_io_lock = self.config_io_lock.clone();
2465        let config = self.config.clone();
2466        let app_data_dir = self.app_data_dir.clone();
2467        let config_facade = self.config_facade.clone();
2468        let account_sink = self.account_sink.clone();
2469        let provider_registry = self.provider_registry.clone();
2470        let provider = self.provider.clone();
2471        let mcp_manager = self.mcp_manager.clone();
2472        let deployed_registry = self.fabric_deployer.registry();
2473        let transaction = tokio::spawn(async move {
2474            let _io = config_io_lock.lock().await;
2475            if id == SectionId::ClusterFabric {
2476                let deployed = deployed_registry.lock().await;
2477                if let Some(node_id) = deployed.keys().find_map(|key| {
2478                    let (source, node_id) = bamboo_server_tools::registry_keys::split(key);
2479                    (source == "node").then(|| node_id.to_string())
2480                }) {
2481                    return Err(ConfigSectionMutationError::Invalid(format!(
2482                        "node '{node_id}' is deployed; stop it before resetting cluster-fabric"
2483                    )));
2484                }
2485            }
2486            ensure_provider_mcp_migration_ready(&app_data_dir)
2487                .map_err(ConfigSectionMutationError::Store)?;
2488            let facade = config_facade.as_ref().ok_or_else(|| {
2489                ConfigSectionMutationError::Invalid(
2490                    "section reset requires the modular configuration facade".to_string(),
2491                )
2492            })?;
2493            let mut candidate = config.read().await.clone();
2494            apply_runtime_section(id, &Config::default(), &mut candidate);
2495            let transaction_dir = app_data_dir.clone();
2496            let commit_facade = facade.clone();
2497            let (mut candidate, revision, cluster_commit, section_commit) =
2498                tokio::task::spawn_blocking(move || {
2499                    if id == SectionId::ClusterFabric {
2500                        let commit =
2501                            bamboo_config::persist_cluster_fabric_reset_at_revision_with_adoption(
2502                                &transaction_dir,
2503                                &mut candidate,
2504                                expected_revision,
2505                                commit_facade.as_ref(),
2506                                |_, _| {},
2507                            )?;
2508                        let revision = commit.revision;
2509                        Ok::<_, ConfigStoreError>((candidate, revision, Some(commit), None))
2510                    } else {
2511                        let commit =
2512                            bamboo_config::persist_credential_backed_section_reset_at_revision_with_adoption(
2513                                &transaction_dir,
2514                                &mut candidate,
2515                                id,
2516                                expected_revision,
2517                                commit_facade.as_ref(),
2518                            )?;
2519                        let revision = commit.revision;
2520                        Ok((candidate, revision, None, Some(commit)))
2521                    }
2522                })
2523                .await
2524                .map_err(|error| {
2525                    ConfigSectionMutationError::Runtime(format!(
2526                        "section reset transaction task failed: {error}"
2527                    ))
2528                })?
2529                .map_err(ConfigSectionMutationError::Store)?;
2530
2531            let cluster_runtime = match cluster_commit {
2532                Some(commit) => {
2533                    let bamboo_config::ClusterFabricTransactionCommit {
2534                        revision: _,
2535                        adoption,
2536                        credential_adoption,
2537                        committed_recovery,
2538                        runtime,
2539                    } = commit;
2540                    let runtime = match runtime {
2541                        Ok(bamboo_config::ClusterFabricRuntimeSnapshot {
2542                            cluster_fabric,
2543                            credential_statuses,
2544                            credential_health,
2545                        }) => {
2546                            candidate.cluster_fabric = cluster_fabric;
2547                            Ok((credential_statuses, credential_health))
2548                        }
2549                        Err(error) if revision == expected_revision => {
2550                            // A semantic no-op did not cross a durable boundary,
2551                            // so preserve the ordinary store error contract.
2552                            return Err(ConfigSectionMutationError::Store(error));
2553                        }
2554                        Err(error) => {
2555                            // The reset metadata and facade are already
2556                            // committed. Publish the exact secret-free reset
2557                            // candidate instead of retaining the pre-reset
2558                            // runtime while reporting the post-commit failure.
2559                            candidate.clear_cluster_runtime_credentials();
2560                            Err(error)
2561                        }
2562                    };
2563                    Some((adoption, credential_adoption, committed_recovery, runtime))
2564                }
2565                None => None,
2566            };
2567            let (section_events, exact_section) = match section_commit {
2568                Some(commit) => {
2569                    let installed = install_credential_section_commit(commit, &mut candidate)
2570                        .map_err(ConfigSectionMutationError::Store)?;
2571                    (installed.events, installed.section)
2572                }
2573                None => (Vec::new(), None),
2574            };
2575            if id == SectionId::Env {
2576                candidate.publish_env_vars();
2577            }
2578            *config.write().await = candidate.clone();
2579            if id != SectionId::ClusterFabric {
2580                publish_exact_facade_events(&account_sink, &section_events)
2581                    .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
2582            }
2583            let commit = if id == SectionId::ClusterFabric {
2584                let (cluster_adoption, credential_adoption, committed_recovery, cluster_runtime) =
2585                    cluster_runtime.expect("cluster reset captures an exact runtime");
2586                let event = match cluster_adoption {
2587                    Some(Ok(event)) => Some(event),
2588                    Some(Err(error)) => {
2589                        return Err(ConfigSectionMutationError::Runtime(format!(
2590                            "cluster reset committed at revision {revision} but process adoption failed: {error}"
2591                        )));
2592                    }
2593                    None if revision == expected_revision => None,
2594                    None => {
2595                        return Err(ConfigSectionMutationError::Runtime(format!(
2596                            "cluster reset committed at revision {revision} without a process adoption result"
2597                        )));
2598                    }
2599                };
2600                let section = facade
2601                    .registry()
2602                    .envelope_value(SectionId::ClusterFabric)
2603                    .map_err(|error| {
2604                        if revision == expected_revision {
2605                            ConfigSectionMutationError::Store(error)
2606                        } else {
2607                            ConfigSectionMutationError::Runtime(format!(
2608                                "cluster reset committed at revision {revision} but its exact envelope is unavailable: {error}"
2609                            ))
2610                        }
2611                    })?;
2612                if section.revision != revision {
2613                    return Err(ConfigSectionMutationError::Runtime(format!(
2614                        "cluster reset committed at revision {revision} but facade retained revision {}",
2615                        section.revision
2616                    )));
2617                }
2618                if let Some(event) = event.as_ref() {
2619                    publish_registry_event(&account_sink, event);
2620                }
2621                if let Err(error) = committed_recovery {
2622                    return Err(ConfigSectionMutationError::Runtime(format!(
2623                        "cluster reset committed at revision {revision} but transaction recovery failed: {error}"
2624                    )));
2625                }
2626                if let Some(Err(error)) = credential_adoption {
2627                    return Err(ConfigSectionMutationError::Runtime(format!(
2628                        "cluster reset committed at revision {revision} but credential facade adoption failed: {error}"
2629                    )));
2630                }
2631                let (credential_statuses, credential_health) =
2632                    cluster_runtime.map_err(|error| {
2633                        ConfigSectionMutationError::Runtime(format!(
2634                            "cluster reset committed at revision {revision} but could not materialize its exact runtime credentials: {error}"
2635                        ))
2636                    })?;
2637                CredentialBackedResetCommit::Cluster(Box::new(
2638                    bamboo_server_tools::FabricCommitSnapshot {
2639                        config: candidate.clone(),
2640                        section,
2641                        credential_statuses,
2642                        credential_health,
2643                    },
2644                ))
2645            } else {
2646                let section = exact_section.ok_or_else(|| {
2647                    ConfigSectionMutationError::Runtime(format!(
2648                        "{} reset committed at revision {revision} without its exact envelope",
2649                        id.descriptor().name
2650                    ))
2651                })?;
2652                if section.revision != revision {
2653                    return Err(ConfigSectionMutationError::Runtime(format!(
2654                        "{} reset committed at revision {revision} but captured revision {}",
2655                        id.descriptor().name,
2656                        section.revision
2657                    )));
2658                }
2659                CredentialBackedResetCommit::Section(section)
2660            };
2661
2662            if id == SectionId::Core {
2663                match bamboo_llm::ProviderRegistry::from_config(&candidate, app_data_dir.clone())
2664                    .await
2665                {
2666                    Ok(registry) => {
2667                        if let Some(candidate_provider) = registry.get_default() {
2668                            provider_registry.replace_with(registry);
2669                            *provider.write().await = candidate_provider;
2670                        }
2671                    }
2672                    Err(error) => {
2673                        tracing::warn!(error = %error, "core reset committed but provider reload failed");
2674                    }
2675                }
2676                mcp_manager.reconcile_from_config(&candidate.mcp).await;
2677            }
2678
2679            Ok(commit)
2680        });
2681        transaction.await.map_err(|error| {
2682            ConfigSectionMutationError::Runtime(format!(
2683                "section reset transaction task failed: {error}"
2684            ))
2685        })?
2686    }
2687}
2688
2689fn credential_reference_inventory(value: &Value) -> std::collections::BTreeMap<String, Value> {
2690    fn collect(value: &Value, path: &str, output: &mut std::collections::BTreeMap<String, Value>) {
2691        match value {
2692            Value::Object(object) => {
2693                for (key, value) in object {
2694                    let child_path =
2695                        format!("{path}/{}", key.replace('~', "~0").replace('/', "~1"));
2696                    let normalized = key
2697                        .chars()
2698                        .filter(|ch| ch.is_ascii_alphanumeric())
2699                        .flat_map(char::to_lowercase)
2700                        .collect::<String>();
2701                    if normalized == "credentialref"
2702                        || normalized.ends_with("credentialref")
2703                        || normalized.ends_with("credentialrefs")
2704                    {
2705                        output.insert(child_path, value.clone());
2706                    } else {
2707                        collect(value, &child_path, output);
2708                    }
2709                }
2710            }
2711            Value::Array(values) => {
2712                for (index, value) in values.iter().enumerate() {
2713                    collect(value, &format!("{path}/{index}"), output);
2714                }
2715            }
2716            _ => {}
2717        }
2718    }
2719
2720    let mut output = std::collections::BTreeMap::new();
2721    collect(value, "", &mut output);
2722    output
2723}
2724
2725fn provider_durable_document(
2726    providers: &ProviderConfigs,
2727) -> Result<ProviderConfigs, ConfigSectionMutationError> {
2728    let mut document = providers.clone();
2729    macro_rules! sanitize {
2730        ($field:ident) => {
2731            if let Some(provider) = document.$field.as_mut() {
2732                provider.api_key.clear();
2733                provider.api_key_encrypted = None;
2734            }
2735        };
2736    }
2737    sanitize!(openai);
2738    sanitize!(anthropic);
2739    sanitize!(gemini);
2740    if let Some(provider) = document.bodhi.as_mut() {
2741        provider.api_key.clear();
2742        provider.api_key_encrypted = None;
2743    }
2744    validate_provider_config(&document).map_err(ConfigSectionMutationError::Invalid)?;
2745    Ok(document)
2746}
2747
2748fn retain_provider_credentials(current: &ProviderConfigs, candidate: &mut ProviderConfigs) {
2749    candidate.extra = current.extra.clone();
2750    macro_rules! retain {
2751        ($field:ident) => {
2752            if let (Some(current), Some(candidate)) = (&current.$field, &mut candidate.$field) {
2753                candidate.api_key = current.api_key.clone();
2754                candidate.api_key_encrypted = current.api_key_encrypted.clone();
2755                if candidate.credential_ref.is_none() {
2756                    candidate.credential_ref = current.credential_ref.clone();
2757                }
2758                if candidate.credential_ref != current.credential_ref {
2759                    candidate.api_key.clear();
2760                    candidate.api_key_encrypted = None;
2761                }
2762                candidate.api_key_from_env = current.api_key_from_env;
2763                candidate.request_overrides = current.request_overrides.clone();
2764                candidate.extra = current.extra.clone();
2765            }
2766        };
2767    }
2768    retain!(openai);
2769    retain!(anthropic);
2770    retain!(gemini);
2771    if let (Some(current), Some(candidate)) = (&current.bodhi, &mut candidate.bodhi) {
2772        candidate.api_key = current.api_key.clone();
2773        candidate.api_key_encrypted = current.api_key_encrypted.clone();
2774        if candidate.credential_ref.is_none() {
2775            candidate.credential_ref = current.credential_ref.clone();
2776        }
2777        if candidate.credential_ref != current.credential_ref {
2778            candidate.api_key.clear();
2779            candidate.api_key_encrypted = None;
2780        }
2781        candidate.extra = current.extra.clone();
2782    }
2783    if let (Some(current), Some(candidate)) = (&current.copilot, &mut candidate.copilot) {
2784        candidate.request_overrides = current.request_overrides.clone();
2785        candidate.extra = current.extra.clone();
2786    }
2787}
2788
2789fn retain_mcp_credentials(
2790    current: &McpConfig,
2791    candidate: &mut McpConfig,
2792    touched: &BTreeSet<bamboo_config::CredentialRef>,
2793) {
2794    for candidate_server in &mut candidate.servers {
2795        let Some(current_server) = current
2796            .servers
2797            .iter()
2798            .find(|server| server.id == candidate_server.id)
2799        else {
2800            continue;
2801        };
2802        if let (TransportConfig::Stdio(current), TransportConfig::Stdio(candidate)) =
2803            (&current_server.transport, &mut candidate_server.transport)
2804        {
2805            if candidate.env.is_empty()
2806                && candidate.env_encrypted.is_empty()
2807                && candidate.env_credential_refs.is_empty()
2808            {
2809                for (name, reference) in &current.env_credential_refs {
2810                    if mcp_credential_ref_is_touched(Some(reference), touched) {
2811                        continue;
2812                    }
2813                    candidate
2814                        .env_credential_refs
2815                        .insert(name.clone(), reference.clone());
2816                    if let Some(value) = current.env.get(name) {
2817                        candidate.env.insert(name.clone(), value.clone());
2818                    }
2819                    if let Some(value) = current.env_encrypted.get(name) {
2820                        candidate.env_encrypted.insert(name.clone(), value.clone());
2821                    }
2822                }
2823            } else {
2824                for (name, reference) in &current.env_credential_refs {
2825                    if candidate.env_credential_refs.get(name) != Some(reference) {
2826                        continue;
2827                    }
2828                    if candidate.env.get(name).is_none_or(|value| value.is_empty()) {
2829                        if let Some(value) = current.env.get(name) {
2830                            candidate.env.insert(name.clone(), value.clone());
2831                        }
2832                    }
2833                }
2834            }
2835        }
2836        match (&current_server.transport, &mut candidate_server.transport) {
2837            (TransportConfig::Sse(current), TransportConfig::Sse(candidate)) => {
2838                retain_mcp_header_credentials(&current.headers, &mut candidate.headers)
2839            }
2840            (
2841                TransportConfig::StreamableHttp(current),
2842                TransportConfig::StreamableHttp(candidate),
2843            ) => retain_mcp_header_credentials(&current.headers, &mut candidate.headers),
2844            _ => {}
2845        }
2846    }
2847}
2848
2849fn materialize_mcp_touched_replacements(
2850    candidate: &mut McpConfig,
2851    touched: &BTreeSet<bamboo_config::CredentialRef>,
2852) -> Result<(), String> {
2853    let mut replacements = BTreeMap::<bamboo_config::CredentialRef, String>::new();
2854    for server in &candidate.servers {
2855        match &server.transport {
2856            TransportConfig::Stdio(stdio) => {
2857                for (name, raw_reference) in &stdio.env_credential_refs {
2858                    let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2859                        .map_err(|_| "MCP credential reference is invalid".to_string())?;
2860                    if let Some(value) = stdio
2861                        .env
2862                        .get(name)
2863                        .filter(|value| touched.contains(&reference) && !value.is_empty())
2864                    {
2865                        insert_mcp_replacement(&mut replacements, reference, value)?;
2866                    }
2867                }
2868            }
2869            TransportConfig::Sse(http) => {
2870                collect_mcp_header_replacements(&http.headers, touched, &mut replacements)?
2871            }
2872            TransportConfig::StreamableHttp(http) => {
2873                collect_mcp_header_replacements(&http.headers, touched, &mut replacements)?
2874            }
2875        }
2876    }
2877    if replacements.is_empty() {
2878        return Ok(());
2879    }
2880    for server in &mut candidate.servers {
2881        match &mut server.transport {
2882            TransportConfig::Stdio(stdio) => {
2883                for (name, raw_reference) in &stdio.env_credential_refs {
2884                    let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2885                        .map_err(|_| "MCP credential reference is invalid".to_string())?;
2886                    if let Some(value) = replacements.get(&reference) {
2887                        stdio.env.insert(name.clone(), value.clone());
2888                    }
2889                }
2890            }
2891            TransportConfig::Sse(http) => {
2892                apply_mcp_header_replacements(&mut http.headers, &replacements)?
2893            }
2894            TransportConfig::StreamableHttp(http) => {
2895                apply_mcp_header_replacements(&mut http.headers, &replacements)?
2896            }
2897        }
2898    }
2899    Ok(())
2900}
2901
2902fn collect_mcp_header_replacements(
2903    headers: &[bamboo_mcp::HeaderConfig],
2904    touched: &BTreeSet<bamboo_config::CredentialRef>,
2905    replacements: &mut BTreeMap<bamboo_config::CredentialRef, String>,
2906) -> Result<(), String> {
2907    for header in headers {
2908        let Some(raw_reference) = header.credential_ref.as_ref() else {
2909            continue;
2910        };
2911        let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2912            .map_err(|_| "MCP credential reference is invalid".to_string())?;
2913        if touched.contains(&reference) && !header.value.is_empty() {
2914            insert_mcp_replacement(replacements, reference, &header.value)?;
2915        }
2916    }
2917    Ok(())
2918}
2919
2920fn insert_mcp_replacement(
2921    replacements: &mut BTreeMap<bamboo_config::CredentialRef, String>,
2922    reference: bamboo_config::CredentialRef,
2923    value: &str,
2924) -> Result<(), String> {
2925    match replacements.get(&reference) {
2926        Some(existing) if existing != value => {
2927            Err("MCP updates assign conflicting values to one credential reference".to_string())
2928        }
2929        Some(_) => Ok(()),
2930        None => {
2931            replacements.insert(reference, value.to_string());
2932            Ok(())
2933        }
2934    }
2935}
2936
2937fn apply_mcp_header_replacements(
2938    headers: &mut [bamboo_mcp::HeaderConfig],
2939    replacements: &BTreeMap<bamboo_config::CredentialRef, String>,
2940) -> Result<(), String> {
2941    for header in headers {
2942        let Some(raw_reference) = header.credential_ref.as_ref() else {
2943            continue;
2944        };
2945        let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2946            .map_err(|_| "MCP credential reference is invalid".to_string())?;
2947        if let Some(value) = replacements.get(&reference) {
2948            header.value = value.clone();
2949        }
2950    }
2951    Ok(())
2952}
2953
2954fn mcp_credential_ref_is_touched(
2955    raw_reference: Option<&String>,
2956    touched: &BTreeSet<bamboo_config::CredentialRef>,
2957) -> bool {
2958    raw_reference
2959        .and_then(|raw| bamboo_config::CredentialRef::parse(raw.clone()).ok())
2960        .is_some_and(|reference| touched.contains(&reference))
2961}
2962
2963fn retain_mcp_header_credentials(
2964    current: &[bamboo_mcp::HeaderConfig],
2965    candidate: &mut [bamboo_mcp::HeaderConfig],
2966) {
2967    for candidate_header in candidate {
2968        let Some(current_header) = current
2969            .iter()
2970            .find(|header| header.name == candidate_header.name)
2971        else {
2972            continue;
2973        };
2974        if candidate_header.credential_ref == current_header.credential_ref
2975            && candidate_header.value.is_empty()
2976        {
2977            candidate_header.value = current_header.value.clone();
2978            candidate_header.value_encrypted = current_header.value_encrypted.clone();
2979        }
2980    }
2981}
2982
2983fn credential_ref_mcp_document(
2984    runtime: &McpConfig,
2985) -> Result<McpConfig, ConfigSectionMutationError> {
2986    let mut document = runtime.clone();
2987    for server in &mut document.servers {
2988        match &mut server.transport {
2989            TransportConfig::Stdio(config) => {
2990                config.env_encrypted.clear();
2991                config.env.retain(|name, value| {
2992                    !(value.is_empty() || config.env_credential_refs.contains_key(name))
2993                });
2994                if !config.env.is_empty() {
2995                    return Err(ConfigSectionMutationError::Invalid(
2996                        "MCP secret requires a credential reference".to_string(),
2997                    ));
2998                }
2999            }
3000            TransportConfig::Sse(config) => reference_headers(&mut config.headers)?,
3001            TransportConfig::StreamableHttp(config) => reference_headers(&mut config.headers)?,
3002        }
3003    }
3004    Ok(document)
3005}
3006
3007fn retain_mcp_credential_refs(document: &McpConfig, runtime: &mut McpConfig) {
3008    for runtime_server in &mut runtime.servers {
3009        let Some(document_server) = document
3010            .servers
3011            .iter()
3012            .find(|server| server.id == runtime_server.id)
3013        else {
3014            continue;
3015        };
3016        match (&document_server.transport, &mut runtime_server.transport) {
3017            (TransportConfig::Stdio(document), TransportConfig::Stdio(runtime)) => {
3018                runtime.env_encrypted.clear();
3019                runtime.env_credential_refs = document.env_credential_refs.clone();
3020            }
3021            (TransportConfig::Sse(document), TransportConfig::Sse(runtime)) => {
3022                copy_header_ciphertext(&document.headers, &mut runtime.headers);
3023            }
3024            (
3025                TransportConfig::StreamableHttp(document),
3026                TransportConfig::StreamableHttp(runtime),
3027            ) => copy_header_ciphertext(&document.headers, &mut runtime.headers),
3028            _ => {}
3029        }
3030    }
3031}
3032
3033fn copy_header_ciphertext(
3034    document: &[bamboo_mcp::HeaderConfig],
3035    runtime: &mut [bamboo_mcp::HeaderConfig],
3036) {
3037    for runtime_header in runtime {
3038        if let Some(document_header) = document
3039            .iter()
3040            .find(|header| header.name == runtime_header.name)
3041        {
3042            runtime_header.value_encrypted = None;
3043            runtime_header.credential_ref = document_header.credential_ref.clone();
3044        }
3045    }
3046}
3047
3048fn reference_headers(
3049    headers: &mut [bamboo_mcp::HeaderConfig],
3050) -> Result<(), ConfigSectionMutationError> {
3051    for header in headers {
3052        if !header.value.is_empty() && header.credential_ref.is_none() {
3053            return Err(ConfigSectionMutationError::Invalid(
3054                "MCP secret requires a credential reference".to_string(),
3055            ));
3056        }
3057        header.value.clear();
3058        header.value_encrypted = None;
3059    }
3060    Ok(())
3061}
3062
3063impl AppState {
3064    /// Reload the provider based on current configuration
3065    ///
3066    /// Re-reads the configuration and creates a new LLM provider
3067    /// instance, allowing runtime switching of providers or models.
3068    ///
3069    /// # Returns
3070    ///
3071    /// `Ok(())` if the provider was successfully reloaded.
3072    ///
3073    /// # Errors
3074    ///
3075    /// Returns an error if:
3076    /// - Configuration cannot be read
3077    /// - Provider initialization fails (e.g., invalid API key)
3078    ///
3079    /// # Example
3080    ///
3081    /// ```rust,no_run
3082    /// use bamboo_server::app_state::AppState;
3083    /// use std::path::PathBuf;
3084    ///
3085    /// #[tokio::main]
3086    /// async fn main() {
3087    ///     let state = AppState::new(PathBuf::from("/path/to/.bamboo"))
3088    ///         .await
3089    ///         .expect("failed to initialize app state");
3090    ///
3091    ///     // User updated config file...
3092    ///     state.reload_provider().await.expect("Provider reload failed");
3093    /// }
3094    /// ```
3095    pub async fn reload_provider(&self) -> Result<(), bamboo_llm::LLMError> {
3096        let config = self.config.read().await.clone();
3097        let candidate_registry =
3098            bamboo_llm::ProviderRegistry::from_config(&config, self.app_data_dir.clone()).await?;
3099        let default_provider_name = candidate_registry.default_provider_name();
3100        tracing::info!(
3101            default_provider = %default_provider_name,
3102            legacy_provider = %config.provider,
3103            has_provider_instances = config.has_provider_instances(),
3104            "Reloading provider runtime from current config"
3105        );
3106
3107        let new_provider = candidate_registry.get_default().ok_or_else(|| {
3108            let message = if config.has_provider_instances() {
3109                format!(
3110                    "Default provider instance '{}' is not available or failed to initialize",
3111                    default_provider_name
3112                )
3113            } else {
3114                format!(
3115                    "Provider '{}' is not available or failed to initialize",
3116                    config.provider
3117                )
3118            };
3119            bamboo_llm::LLMError::Auth(message)
3120        })?;
3121
3122        let mut provider = self.provider.write().await;
3123        self.provider_registry.replace_with(candidate_registry);
3124        *provider = new_provider;
3125
3126        tracing::info!(
3127            default_provider = %default_provider_name,
3128            "Provider reloaded successfully"
3129        );
3130        Ok(())
3131    }
3132
3133    /// Reload the configuration from file
3134    ///
3135    /// Reads the configuration file again and updates the in-memory
3136    /// config. Note: This does NOT automatically reload the provider;
3137    /// call `reload_provider()` afterwards if needed.
3138    ///
3139    /// # Returns
3140    ///
3141    /// The newly loaded configuration.
3142    ///
3143    /// # Example
3144    ///
3145    /// ```rust,no_run
3146    /// use bamboo_server::app_state::AppState;
3147    /// use std::path::PathBuf;
3148    ///
3149    /// #[tokio::main]
3150    /// async fn main() {
3151    ///     let state = AppState::new(PathBuf::from("/path/to/.bamboo"))
3152    ///         .await
3153    ///         .expect("failed to initialize app state");
3154    ///
3155    ///     // Reload config from disk
3156    ///     let new_config = state.reload_config().await;
3157    ///
3158    ///     // Optionally reload provider with new config
3159    ///     state.reload_provider().await.ok();
3160    /// }
3161    /// ```
3162    pub async fn reload_config(&self) -> Config {
3163        // The process facade is the only production read authority. Its
3164        // watcher owns disk reloads, so this compatibility method republishes
3165        // the current immutable facade view instead of constructing a second
3166        // disk-reading Config authority.
3167        let _io = self.config_io_lock.lock().await;
3168        let mut config = self.config.write().await;
3169        let new_config = self
3170            .config_facade
3171            .as_ref()
3172            .map(|facade| load_facade_effective_config(facade, &self.app_data_dir))
3173            .unwrap_or_else(|| {
3174                Config::from_data_dir_without_publish(Some(self.app_data_dir.clone()))
3175            });
3176        new_config.publish_env_vars();
3177        *config = new_config.clone();
3178        new_config
3179    }
3180
3181    async fn persist_config_snapshot(
3182        data_dir: PathBuf,
3183        config_facade: Option<Arc<bamboo_config::ConfigFacade>>,
3184        config: Config,
3185    ) -> Result<Option<bamboo_config::FacadeConfigCommit>, AppError> {
3186        if let Some(facade) = config_facade {
3187            tokio::task::spawn_blocking(move || {
3188                let result = bamboo_config::persist_facade_effective_config_with_adoption(
3189                    &data_dir,
3190                    &config,
3191                    facade.as_ref(),
3192                );
3193                #[cfg(test)]
3194                if result.is_ok() {
3195                    run_generic_before_event_test_hook(&data_dir);
3196                }
3197                result
3198            })
3199            .await
3200            .map_err(|error| {
3201                AppError::InternalError(anyhow::anyhow!("Config save task failed: {error}"))
3202            })?
3203            .map(Some)
3204            .map_err(map_exact_credential_store_error)
3205        } else {
3206            tokio::task::spawn_blocking(move || {
3207                let result = config.save_to_dir(data_dir.clone());
3208                #[cfg(test)]
3209                if result.is_ok() {
3210                    run_generic_before_event_test_hook(&data_dir);
3211                }
3212                result
3213            })
3214            .await
3215            .map_err(|error| {
3216                AppError::InternalError(anyhow::anyhow!("Config save task failed: {error}"))
3217            })?
3218            .map_err(|error| {
3219                AppError::InternalError(anyhow::anyhow!("Failed to save config: {error}"))
3220            })?;
3221            Ok(None)
3222        }
3223    }
3224
3225    /// Unified config update entrypoint.
3226    ///
3227    /// Invariants:
3228    /// - Build and validate a detached candidate
3229    /// - Persist to disk before publishing it in memory
3230    /// - Apply runtime side-effects last (provider reload, MCP reconcile)
3231    pub async fn update_config<F>(
3232        &self,
3233        update: F,
3234        effects: ConfigUpdateEffects,
3235    ) -> Result<Config, AppError>
3236    where
3237        F: FnOnce(&mut Config) -> Result<(), AppError>,
3238    {
3239        // Cancellation while waiting for either guard is pre-commit and leaves
3240        // every authority untouched. After candidate construction there is no
3241        // await before the owned guard and candidate enter the detached task.
3242        let io = self.config_io_lock.clone().lock_owned().await;
3243        let (mut snapshot, live_base, enforcement_newly_off) = {
3244            let cfg = self.config.read().await;
3245            // Refuse the whole operation (no in-memory mutation, no disk
3246            // write) while a config-corruption recovery is pending
3247            // confirmation (#153) — `save_to_dir` would reject the persist
3248            // anyway, but checking here BEFORE `update()` runs keeps the
3249            // in-memory config frozen exactly at the recovered state.
3250            reject_if_recovery_pending(&cfg)?;
3251            let was_off = cfg.plugin_trust.enforcement_is_off();
3252            let live_base = cfg.clone();
3253            let mut candidate = cfg.clone();
3254            restore_authoritative_cluster_fabric(self.config_facade.as_ref(), &mut candidate);
3255            update(&mut candidate)?;
3256            // No caller of this compatibility entrypoint owns cluster CAS.
3257            restore_authoritative_cluster_fabric(self.config_facade.as_ref(), &mut candidate);
3258            if self.config_facade.is_none() {
3259                candidate.assign_connect_platform_ids();
3260                candidate.refresh_encrypted_secrets().map_err(|e| {
3261                    AppError::InternalError(anyhow::anyhow!(
3262                        "Failed to refresh encrypted secrets: {e}"
3263                    ))
3264                })?;
3265            }
3266            let newly_off = !was_off && candidate.plugin_trust.enforcement_is_off();
3267            (candidate, live_base, newly_off)
3268        };
3269        if enforcement_newly_off {
3270            warn_plugin_trust_enforcement_off();
3271        }
3272        let config = self.config.clone();
3273        let app_data_dir = self.app_data_dir.clone();
3274        let config_facade = self.config_facade.clone();
3275        let account_sink = self.account_sink.clone();
3276        let provider_registry = self.provider_registry.clone();
3277        let provider = self.provider.clone();
3278        let mcp_manager = self.mcp_manager.clone();
3279        // The detached task owns every step after dispatch. Dropping the
3280        // request's JoinHandle cannot strand a completed durable/facade commit
3281        // ahead of live publication, its exact events, or requested effects.
3282        let transaction = tokio::spawn(async move {
3283            // Hold the config-IO lock across BOTH the in-memory mutation AND the
3284            // disk persist, so a concurrent reload_config can't read the disk in
3285            // the gap before we persist and then clobber this mutation with the
3286            // stale copy (#126). Effects stay inside the same owned lifetime:
3287            // otherwise a later writer can publish its runtime and then be
3288            // overwritten by this task's older async provider/MCP effects.
3289            let snapshot = {
3290                let _io = io;
3291                let commit = Self::persist_config_snapshot(
3292                    app_data_dir.clone(),
3293                    config_facade.clone(),
3294                    snapshot.clone(),
3295                )
3296                .await?;
3297                let events = match commit {
3298                    Some(commit) => {
3299                        let mut published = live_base;
3300                        let events =
3301                            install_facade_config_commit(commit, &mut published).map_err(|e| {
3302                                AppError::InternalError(anyhow::anyhow!(
3303                                    "failed to install committed configuration section: {e}"
3304                                ))
3305                            })?;
3306                        snapshot = published;
3307                        events
3308                    }
3309                    None => Vec::new(),
3310                };
3311                {
3312                    let mut cfg = config.write().await;
3313                    snapshot.publish_env_vars();
3314                    *cfg = snapshot.clone();
3315                }
3316                // Keep synchronous event enqueue inside the same serialization
3317                // boundary as durable commit and live installation. Otherwise a
3318                // later local writer can enqueue r2 before this task enqueues r1.
3319                publish_exact_facade_events(&account_sink, &events)?;
3320                Self::apply_config_effects_owned(
3321                    snapshot.clone(),
3322                    effects,
3323                    app_data_dir,
3324                    config,
3325                    provider_registry,
3326                    provider,
3327                    mcp_manager,
3328                )
3329                .await?;
3330                snapshot
3331            };
3332            Ok::<_, AppError>(snapshot)
3333        });
3334        transaction.await.map_err(|error| {
3335            AppError::InternalError(anyhow::anyhow!(
3336                "config update transaction task failed: {error}"
3337            ))
3338        })?
3339    }
3340
3341    /// Compatibility provider update whose credential and metadata documents
3342    /// share the recoverable config transaction manifest.
3343    pub async fn update_config_with_provider_credentials<F>(
3344        &self,
3345        update: F,
3346        provider_intents: std::collections::BTreeSet<String>,
3347        provider_instance_intents: std::collections::BTreeSet<String>,
3348        effects: ConfigUpdateEffects,
3349    ) -> Result<Config, AppError>
3350    where
3351        F: FnOnce(&mut Config) -> Result<(), AppError>,
3352    {
3353        if provider_intents.is_empty() && provider_instance_intents.is_empty() {
3354            return self.update_config(update, effects).await;
3355        }
3356        let io = self.config_io_lock.clone().lock_owned().await;
3357        let config_facade = self.config_facade.clone();
3358        let (mut candidate, live_base, enforcement_newly_off) = {
3359            let cfg = self.config.read().await;
3360            reject_if_recovery_pending(&cfg)?;
3361            let was_off = cfg.plugin_trust.enforcement_is_off();
3362            let live_base = cfg.clone();
3363            let mut candidate = cfg.clone();
3364            restore_authoritative_cluster_fabric(config_facade.as_ref(), &mut candidate);
3365            update(&mut candidate)?;
3366            // Provider compatibility updates never own cluster CAS.
3367            restore_authoritative_cluster_fabric(config_facade.as_ref(), &mut candidate);
3368            // Provider plaintext may be present until the exact credential
3369            // transaction assigns its durable reference. Compare every other
3370            // section against a candidate whose provider projection is
3371            // replaced with the current durable domain.
3372            let mut non_provider_candidate = candidate.clone();
3373            apply_runtime_section(SectionId::Providers, &cfg, &mut non_provider_candidate);
3374            let mut comparison_base = cfg.clone();
3375            restore_authoritative_cluster_fabric(config_facade.as_ref(), &mut comparison_base);
3376            let mut changed =
3377                bamboo_config::changed_facade_sections(&comparison_base, &non_provider_candidate)
3378                    .map_err(|_| {
3379                    AppError::InternalError(anyhow::anyhow!(
3380                        "failed to compare modular configuration sections"
3381                    ))
3382                })?;
3383            if serde_json::to_value(cfg.subagents()).ok()
3384                == serde_json::to_value(candidate.subagents()).ok()
3385            {
3386                changed.retain(|section| *section != SectionId::Subagents);
3387            }
3388            if let Some(other) = changed
3389                .into_iter()
3390                .find(|section| *section != SectionId::Providers)
3391            {
3392                return Err(AppError::BadRequest(format!(
3393                    "provider credential updates cannot be combined with {} changes; split the request",
3394                    other.descriptor().name
3395                )));
3396            }
3397            if config_facade.is_none() {
3398                candidate.assign_connect_platform_ids();
3399                candidate.refresh_encrypted_secrets().map_err(|error| {
3400                    AppError::InternalError(anyhow::anyhow!(
3401                        "Failed to refresh encrypted secrets: {error}"
3402                    ))
3403                })?;
3404            }
3405            let newly_off = !was_off && candidate.plugin_trust.enforcement_is_off();
3406            (candidate, live_base, newly_off)
3407        };
3408        let config = self.config.clone();
3409        let app_data_dir = self.app_data_dir.clone();
3410        let account_sink = self.account_sink.clone();
3411        let provider_registry = self.provider_registry.clone();
3412        let provider = self.provider.clone();
3413        let mcp_manager = self.mcp_manager.clone();
3414        let transaction = tokio::spawn(async move {
3415            let snapshot = {
3416                let _io = io;
3417                let data_dir = app_data_dir.clone();
3418                let commit_facade = config_facade.clone();
3419                let (candidate, commit) = tokio::task::spawn_blocking(move || {
3420                    let result = if let Some(facade) = commit_facade {
3421                        let commit =
3422                            bamboo_config::persist_provider_instance_credential_transaction_with_adoption(
3423                                &data_dir,
3424                                &mut candidate,
3425                                &provider_intents,
3426                                &provider_instance_intents,
3427                                facade.as_ref(),
3428                            )?;
3429                        Ok::<_, ConfigStoreError>((candidate, Some(commit)))
3430                    } else {
3431                        bamboo_config::persist_provider_instance_credential_transaction(
3432                            &data_dir,
3433                            &mut candidate,
3434                            &provider_intents,
3435                            &provider_instance_intents,
3436                        )?;
3437                        Ok((load_committed_effective_config(&data_dir)?, None))
3438                    };
3439                    #[cfg(test)]
3440                    run_generic_before_event_test_hook(&data_dir);
3441                    result
3442                })
3443                .await
3444                .map_err(|error| {
3445                    AppError::InternalError(anyhow::anyhow!(
3446                        "provider credential transaction task failed: {error}"
3447                    ))
3448                })?
3449                .map_err(|error| match error {
3450                    ConfigStoreError::Conflict { expected, actual } => {
3451                        AppError::ConfigConflict { expected, actual }
3452                    }
3453                    ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3454                    ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(
3455                        anyhow::anyhow!("configuration commit outcome is indeterminate: {message}"),
3456                    ),
3457                    ConfigStoreError::Io(error) => AppError::StorageError(error),
3458                    ConfigStoreError::Json(_) => {
3459                        AppError::BadRequest("configuration document is invalid".to_string())
3460                    }
3461                    ConfigStoreError::Watch(error) => AppError::InternalError(anyhow::anyhow!(
3462                        "configuration watch failed: {error}"
3463                    )),
3464                })?;
3465                let (snapshot, events) = match commit {
3466                    Some(commit) => {
3467                        let mut published = live_base;
3468                        let installed = install_credential_section_commit(commit, &mut published)
3469                            .map_err(|error| {
3470                            AppError::InternalError(anyhow::anyhow!(
3471                                "provider process adoption failed: {error}"
3472                            ))
3473                        })?;
3474                        (published, installed.events)
3475                    }
3476                    None => (candidate, Vec::new()),
3477                };
3478                {
3479                    let mut cfg = config.write().await;
3480                    snapshot.publish_env_vars();
3481                    *cfg = snapshot.clone();
3482                }
3483                publish_exact_facade_events(&account_sink, &events)?;
3484                if enforcement_newly_off {
3485                    warn_plugin_trust_enforcement_off();
3486                }
3487                Self::apply_config_effects_owned(
3488                    snapshot.clone(),
3489                    effects,
3490                    app_data_dir,
3491                    config,
3492                    provider_registry,
3493                    provider,
3494                    mcp_manager,
3495                )
3496                .await?;
3497                snapshot
3498            };
3499            Ok::<_, AppError>(snapshot)
3500        });
3501        transaction.await.map_err(|error| {
3502            AppError::InternalError(anyhow::anyhow!(
3503                "provider config transaction task failed: {error}"
3504            ))
3505        })?
3506    }
3507
3508    /// Mutate user env vars through the recoverable Env-section + credential
3509    /// exact transaction. The detached task owns the mutation so request
3510    /// cancellation cannot strand durable metadata ahead of runtime.
3511    pub async fn update_env_var_credentials<F>(
3512        &self,
3513        expected_revision: u64,
3514        mut env_intents: std::collections::BTreeSet<String>,
3515        full_replace: bool,
3516        update: F,
3517    ) -> Result<
3518        (
3519            Config,
3520            u64,
3521            bamboo_config::CredentialSectionRuntimeMetadata,
3522            Option<bamboo_config::SectionEnvelope<Value>>,
3523        ),
3524        AppError,
3525    >
3526    where
3527        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
3528    {
3529        let config_io_lock = self.config_io_lock.clone();
3530        let config = self.config.clone();
3531        let app_data_dir = self.app_data_dir.clone();
3532        let account_sink = self.account_sink.clone();
3533        let config_facade = self.config_facade.clone();
3534        let transaction = tokio::spawn(async move {
3535            let _io = config_io_lock.lock().await;
3536            let live_base = {
3537                let current = config.read().await;
3538                reject_if_recovery_pending(&current)?;
3539                current.clone()
3540            };
3541            let mut candidate = live_base.clone();
3542            if config_facade.is_some() {
3543                install_exact_credential_section_mutation_base(
3544                    app_data_dir.clone(),
3545                    SectionId::Env,
3546                    expected_revision,
3547                    &mut candidate,
3548                )
3549                .await?;
3550            }
3551            if full_replace {
3552                env_intents.extend(candidate.env_vars.iter().map(|entry| entry.name.clone()));
3553            }
3554            update(&mut candidate)?;
3555            if config_facade.is_none() {
3556                candidate.assign_connect_platform_ids();
3557            }
3558            let transaction_dir = app_data_dir.clone();
3559            let commit_facade = config_facade.clone();
3560            let (candidate, revision, commit) = tokio::task::spawn_blocking(move || {
3561                if let Some(facade) = commit_facade {
3562                    let commit =
3563                        bamboo_config::persist_env_var_credential_transaction_at_revision_with_adoption(
3564                            &transaction_dir,
3565                            &mut candidate,
3566                            &env_intents,
3567                            expected_revision,
3568                            facade.as_ref(),
3569                        )?;
3570                    #[cfg(test)]
3571                    run_credential_after_commit_before_live_test_hook(
3572                        &transaction_dir,
3573                        SectionId::Env,
3574                    );
3575                    let revision = commit.revision;
3576                    Ok::<_, ConfigStoreError>((candidate, revision, Some(commit)))
3577                } else {
3578                    let revision =
3579                        bamboo_config::persist_env_var_credential_transaction_at_revision(
3580                            &transaction_dir,
3581                            &mut candidate,
3582                            &env_intents,
3583                            expected_revision,
3584                        )?;
3585                    Ok((
3586                        load_committed_effective_config(&transaction_dir)?,
3587                        revision,
3588                        None,
3589                    ))
3590                }
3591            })
3592            .await
3593            .map_err(|error| {
3594                AppError::InternalError(anyhow::anyhow!(
3595                    "env credential transaction task failed: {error}"
3596                ))
3597            })?
3598            .map_err(|error| match error {
3599                ConfigStoreError::Conflict { expected, actual } => {
3600                    AppError::ConfigConflict { expected, actual }
3601                }
3602                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3603                ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(
3604                    anyhow::anyhow!("configuration commit outcome is indeterminate: {message}"),
3605                ),
3606                ConfigStoreError::Io(error) => AppError::StorageError(error),
3607                ConfigStoreError::Json(_) => {
3608                    AppError::BadRequest("configuration document is invalid".to_string())
3609                }
3610                ConfigStoreError::Watch(error) => {
3611                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
3612                }
3613            })?;
3614            let (published, installed) = match commit {
3615                Some(commit) => {
3616                    let mut published = live_base;
3617                    let installed = install_credential_section_commit(commit, &mut published)
3618                        .map_err(|error| {
3619                            AppError::InternalError(anyhow::anyhow!(
3620                                "env process adoption failed: {error}"
3621                            ))
3622                        })?;
3623                    (published, installed)
3624                }
3625                None => (
3626                    candidate,
3627                    InstalledCredentialSectionCommit {
3628                        events: Vec::new(),
3629                        metadata: read_credential_runtime_metadata(&app_data_dir).map_err(
3630                            |error| {
3631                                AppError::InternalError(anyhow::anyhow!(
3632                                    "env credential status unavailable after commit: {error}"
3633                                ))
3634                            },
3635                        )?,
3636                        section: None,
3637                    },
3638                ),
3639            };
3640            published.publish_env_vars();
3641            *config.write().await = published.clone();
3642            publish_exact_facade_events(&account_sink, &installed.events)?;
3643            let section = installed.section;
3644            Ok::<_, AppError>((published, revision, installed.metadata, section))
3645        });
3646        transaction.await.map_err(|error| {
3647            AppError::InternalError(anyhow::anyhow!(
3648                "env credential transaction task failed: {error}"
3649            ))
3650        })?
3651    }
3652
3653    /// Mutate notification metadata and ntfy/Bark credentials through the
3654    /// recoverable Notifications-section + credential exact transaction. The
3655    /// detached task completes durable commit and live publication even if the
3656    /// initiating HTTP request is cancelled.
3657    pub async fn update_notification_credentials<F>(
3658        &self,
3659        expected_revision: u64,
3660        secret_intents: std::collections::BTreeSet<String>,
3661        reset_domain: bool,
3662        update: F,
3663    ) -> Result<
3664        (
3665            Config,
3666            u64,
3667            bamboo_config::CredentialSectionRuntimeMetadata,
3668            Option<bamboo_config::SectionEnvelope<Value>>,
3669        ),
3670        AppError,
3671    >
3672    where
3673        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
3674    {
3675        let config_io_lock = self.config_io_lock.clone();
3676        let config = self.config.clone();
3677        let app_data_dir = self.app_data_dir.clone();
3678        let account_sink = self.account_sink.clone();
3679        let config_facade = self.config_facade.clone();
3680        let transaction = tokio::spawn(async move {
3681            let _io = config_io_lock.lock().await;
3682            let live_base = {
3683                let current = config.read().await;
3684                reject_if_recovery_pending(&current)?;
3685                current.clone()
3686            };
3687            let mut candidate = live_base.clone();
3688            if config_facade.is_some() {
3689                install_exact_credential_section_mutation_base(
3690                    app_data_dir.clone(),
3691                    SectionId::Notifications,
3692                    expected_revision,
3693                    &mut candidate,
3694                )
3695                .await?;
3696            }
3697            update(&mut candidate)?;
3698            let transaction_dir = app_data_dir.clone();
3699            let commit_facade = config_facade.clone();
3700            let (candidate, revision, commit) = tokio::task::spawn_blocking(move || {
3701                if let Some(facade) = commit_facade {
3702                    let commit =
3703                        bamboo_config::persist_notification_credential_transaction_at_revision_with_reset_and_adoption(
3704                            &transaction_dir,
3705                            &mut candidate,
3706                            &secret_intents,
3707                            reset_domain,
3708                            expected_revision,
3709                            facade.as_ref(),
3710                        )?;
3711                    let revision = commit.revision;
3712                    Ok::<_, ConfigStoreError>((candidate, revision, Some(commit)))
3713                } else {
3714                    let revision =
3715                        bamboo_config::persist_notification_credential_transaction_at_revision_with_reset(
3716                            &transaction_dir,
3717                            &mut candidate,
3718                            &secret_intents,
3719                            reset_domain,
3720                            expected_revision,
3721                        )?;
3722                    Ok((
3723                        load_committed_effective_config(&transaction_dir)?,
3724                        revision,
3725                        None,
3726                    ))
3727                }
3728            })
3729            .await
3730            .map_err(|error| {
3731                AppError::InternalError(anyhow::anyhow!(
3732                    "notification credential transaction task failed: {error}"
3733                ))
3734            })?
3735            .map_err(|error| match error {
3736                ConfigStoreError::Conflict { expected, actual } => {
3737                    AppError::ConfigConflict { expected, actual }
3738                }
3739                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3740                ConfigStoreError::CommitIndeterminate(message) => {
3741                    AppError::InternalError(anyhow::anyhow!(
3742                        "configuration commit outcome is indeterminate: {message}"
3743                    ))
3744                }
3745                ConfigStoreError::Io(error) => AppError::StorageError(error),
3746                ConfigStoreError::Json(_) => {
3747                    AppError::BadRequest("configuration document is invalid".to_string())
3748                }
3749                ConfigStoreError::Watch(error) => {
3750                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
3751                }
3752            })?;
3753            let (published, installed) = match commit {
3754                Some(commit) => {
3755                    let mut published = live_base;
3756                    let installed = install_credential_section_commit(commit, &mut published)
3757                        .map_err(|error| {
3758                            AppError::InternalError(anyhow::anyhow!(
3759                                "notification process adoption failed: {error}"
3760                            ))
3761                        })?;
3762                    (published, installed)
3763                }
3764                None => (
3765                    candidate,
3766                    InstalledCredentialSectionCommit {
3767                        events: Vec::new(),
3768                        metadata: read_credential_runtime_metadata(&app_data_dir).map_err(
3769                            |error| {
3770                                AppError::InternalError(anyhow::anyhow!(
3771                                    "notification credential status unavailable after commit: {error}"
3772                                ))
3773                            },
3774                        )?,
3775                        section: None,
3776                    },
3777                ),
3778            };
3779            *config.write().await = published.clone();
3780            publish_exact_facade_events(&account_sink, &installed.events)?;
3781            let section = installed.section;
3782            Ok::<_, AppError>((published, revision, installed.metadata, section))
3783        });
3784        transaction.await.map_err(|error| {
3785            AppError::InternalError(anyhow::anyhow!(
3786                "notification credential transaction task failed: {error}"
3787            ))
3788        })?
3789    }
3790
3791    /// Replace connect metadata and explicitly touched platform credentials
3792    /// through the active connect-section + credential exact transaction.
3793    /// The detached task owns durable commit and runtime publication so a
3794    /// cancelled HTTP request cannot leave memory behind the committed pair.
3795    pub async fn update_connect_credentials<F>(
3796        &self,
3797        expected_revision: u64,
3798        secret_intents: bamboo_config::patch::ConnectSecretIntents,
3799        update: F,
3800    ) -> Result<
3801        (
3802            Config,
3803            u64,
3804            bamboo_config::CredentialSectionRuntimeMetadata,
3805            Option<bamboo_config::SectionEnvelope<Value>>,
3806        ),
3807        AppError,
3808    >
3809    where
3810        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
3811    {
3812        let config_io_lock = self.config_io_lock.clone();
3813        let config = self.config.clone();
3814        let app_data_dir = self.app_data_dir.clone();
3815        let account_sink = self.account_sink.clone();
3816        let config_facade = self.config_facade.clone();
3817        let transaction = tokio::spawn(async move {
3818            let _io = config_io_lock.lock().await;
3819            let live_base = {
3820                let current = config.read().await;
3821                reject_if_recovery_pending(&current)?;
3822                current.clone()
3823            };
3824            let mut candidate = live_base.clone();
3825            if config_facade.is_some() {
3826                install_exact_credential_section_mutation_base(
3827                    app_data_dir.clone(),
3828                    SectionId::Connect,
3829                    expected_revision,
3830                    &mut candidate,
3831                )
3832                .await?;
3833            }
3834            update(&mut candidate)?;
3835            candidate.assign_connect_platform_ids();
3836            let transaction_dir = app_data_dir.clone();
3837            let commit_facade = config_facade.clone();
3838            let (candidate, revision, commit) = tokio::task::spawn_blocking(move || {
3839                if let Some(facade) = commit_facade {
3840                    let commit =
3841                        bamboo_config::persist_connect_credential_transaction_at_revision_with_adoption(
3842                            &transaction_dir,
3843                            &mut candidate,
3844                            &secret_intents,
3845                            expected_revision,
3846                            facade.as_ref(),
3847                        )?;
3848                    let revision = commit.revision;
3849                    Ok::<_, ConfigStoreError>((candidate, revision, Some(commit)))
3850                } else {
3851                    let revision =
3852                        bamboo_config::persist_connect_credential_transaction_at_revision(
3853                            &transaction_dir,
3854                            &mut candidate,
3855                            &secret_intents,
3856                            expected_revision,
3857                        )?;
3858                    Ok((
3859                        load_committed_effective_config(&transaction_dir)?,
3860                        revision,
3861                        None,
3862                    ))
3863                }
3864            })
3865            .await
3866            .map_err(|error| {
3867                AppError::InternalError(anyhow::anyhow!(
3868                    "connect credential transaction task failed: {error}"
3869                ))
3870            })?
3871            .map_err(|error| match error {
3872                ConfigStoreError::Conflict { expected, actual } => {
3873                    AppError::ConfigConflict { expected, actual }
3874                }
3875                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3876                ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(
3877                    anyhow::anyhow!("configuration commit outcome is indeterminate: {message}"),
3878                ),
3879                ConfigStoreError::Io(error) => AppError::StorageError(error),
3880                ConfigStoreError::Json(_) => {
3881                    AppError::BadRequest("configuration document is invalid".to_string())
3882                }
3883                ConfigStoreError::Watch(error) => {
3884                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
3885                }
3886            })?;
3887            let (published, installed) = match commit {
3888                Some(commit) => {
3889                    let mut published = live_base;
3890                    let installed = install_credential_section_commit(commit, &mut published)
3891                        .map_err(|error| {
3892                            AppError::InternalError(anyhow::anyhow!(
3893                                "connect process adoption failed: {error}"
3894                            ))
3895                        })?;
3896                    (published, installed)
3897                }
3898                None => (
3899                    candidate,
3900                    InstalledCredentialSectionCommit {
3901                        events: Vec::new(),
3902                        metadata: read_credential_runtime_metadata(&app_data_dir).map_err(
3903                            |error| {
3904                                AppError::InternalError(anyhow::anyhow!(
3905                                    "connect credential status unavailable after commit: {error}"
3906                                ))
3907                            },
3908                        )?,
3909                        section: None,
3910                    },
3911                ),
3912            };
3913            *config.write().await = published.clone();
3914            publish_exact_facade_events(&account_sink, &installed.events)?;
3915            let section = installed.section;
3916            Ok::<_, AppError>((published, revision, installed.metadata, section))
3917        });
3918        transaction.await.map_err(|error| {
3919            AppError::InternalError(anyhow::anyhow!(
3920                "connect credential transaction task failed: {error}"
3921            ))
3922        })?
3923    }
3924
3925    /// Mutate access-control metadata and verifier records through the active
3926    /// access-control section + credential exact transaction.
3927    pub async fn update_access_control_credentials<F>(
3928        &self,
3929        expected_revision: u64,
3930        password_intent: bool,
3931        device_intents: std::collections::BTreeSet<String>,
3932        update: F,
3933    ) -> Result<
3934        (
3935            Config,
3936            u64,
3937            bamboo_config::CredentialSectionRuntimeMetadata,
3938            Option<bamboo_config::SectionEnvelope<Value>>,
3939        ),
3940        AppError,
3941    >
3942    where
3943        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
3944    {
3945        let config_io_lock = self.config_io_lock.clone();
3946        let config = self.config.clone();
3947        let app_data_dir = self.app_data_dir.clone();
3948        let account_sink = self.account_sink.clone();
3949        let config_facade = self.config_facade.clone();
3950        let transaction = tokio::spawn(async move {
3951            let _io = config_io_lock.lock().await;
3952            let live_base = {
3953                let current = config.read().await;
3954                reject_if_recovery_pending(&current)?;
3955                current.clone()
3956            };
3957            let mut candidate = live_base.clone();
3958            if config_facade.is_some() {
3959                install_exact_credential_section_mutation_base(
3960                    app_data_dir.clone(),
3961                    SectionId::AccessControl,
3962                    expected_revision,
3963                    &mut candidate,
3964                )
3965                .await?;
3966            }
3967            update(&mut candidate)?;
3968            let transaction_dir = app_data_dir.clone();
3969            let commit_facade = config_facade.clone();
3970            let (candidate, revision, commit) = tokio::task::spawn_blocking(move || {
3971                if let Some(facade) = commit_facade {
3972                    let commit =
3973                        bamboo_config::persist_access_control_credential_transaction_at_revision_with_adoption(
3974                        &transaction_dir,
3975                        &mut candidate,
3976                        password_intent,
3977                        &device_intents,
3978                        expected_revision,
3979                        facade.as_ref(),
3980                    )?;
3981                    let revision = commit.revision;
3982                    Ok::<_, ConfigStoreError>((candidate, revision, Some(commit)))
3983                } else {
3984                    let revision =
3985                        bamboo_config::persist_access_control_credential_transaction_at_revision(
3986                            &transaction_dir,
3987                            &mut candidate,
3988                            password_intent,
3989                            &device_intents,
3990                            expected_revision,
3991                        )?;
3992                    Ok((
3993                        load_committed_effective_config(&transaction_dir)?,
3994                        revision,
3995                        None,
3996                    ))
3997                }
3998            })
3999            .await
4000            .map_err(|error| {
4001                AppError::InternalError(anyhow::anyhow!(
4002                    "access-control credential transaction task failed: {error}"
4003                ))
4004            })?
4005            .map_err(|error| match error {
4006                ConfigStoreError::Conflict { expected, actual } => {
4007                    AppError::ConfigConflict { expected, actual }
4008                }
4009                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
4010                ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(
4011                    anyhow::anyhow!("configuration commit outcome is indeterminate: {message}"),
4012                ),
4013                ConfigStoreError::Io(error) => AppError::StorageError(error),
4014                ConfigStoreError::Json(_) => {
4015                    AppError::BadRequest("configuration document is invalid".to_string())
4016                }
4017                ConfigStoreError::Watch(error) => {
4018                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
4019                }
4020            })?;
4021            let (published, installed) = match commit {
4022                Some(commit) => {
4023                    let mut published = live_base;
4024                    let installed = install_credential_section_commit(commit, &mut published)
4025                        .map_err(|error| {
4026                            AppError::InternalError(anyhow::anyhow!(
4027                                "access-control process adoption failed: {error}"
4028                            ))
4029                        })?;
4030                    (published, installed)
4031                }
4032                None => (
4033                    candidate,
4034                    InstalledCredentialSectionCommit {
4035                        events: Vec::new(),
4036                        metadata: read_credential_runtime_metadata(&app_data_dir).map_err(
4037                            |error| {
4038                                AppError::InternalError(anyhow::anyhow!(
4039                                    "access-control credential status unavailable after commit: {error}"
4040                                ))
4041                            },
4042                        )?,
4043                        section: None,
4044                    },
4045                ),
4046            };
4047            *config.write().await = published.clone();
4048            publish_exact_facade_events(&account_sink, &installed.events)?;
4049            let section = installed.section;
4050            Ok::<_, AppError>((published, revision, installed.metadata, section))
4051        });
4052        transaction.await.map_err(|error| {
4053            AppError::InternalError(anyhow::anyhow!(
4054                "access-control credential transaction task failed: {error}"
4055            ))
4056        })?
4057    }
4058
4059    /// Mutate cluster-fabric node metadata and SSH credentials through the
4060    /// recoverable Cluster-section + credential exact transaction. The
4061    /// detached task owns durable commit, committed-root reload, and live
4062    /// publication so request cancellation cannot leave runtime behind disk.
4063    pub async fn update_cluster_fabric_credentials<F>(
4064        &self,
4065        expected_revision: u64,
4066        node_intents: std::collections::BTreeMap<
4067            String,
4068            bamboo_config::ClusterNodeCredentialIntents,
4069        >,
4070        update: F,
4071    ) -> Result<bamboo_server_tools::FabricCommitSnapshot, AppError>
4072    where
4073        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
4074    {
4075        self.update_cluster_fabric_credentials_guarded(
4076            expected_revision,
4077            node_intents,
4078            None,
4079            update,
4080        )
4081        .await
4082    }
4083
4084    /// Delete one cluster node only while its lifecycle registry entry is
4085    /// absent. The guard is evaluated after acquiring `config_io_lock`, the
4086    /// same lock used by deploy/stop, so the check and durable mutation cannot
4087    /// race a worker lifecycle transition.
4088    pub(crate) async fn delete_cluster_node_credentials<F>(
4089        &self,
4090        expected_revision: u64,
4091        node_id: String,
4092        node_intents: std::collections::BTreeMap<
4093            String,
4094            bamboo_config::ClusterNodeCredentialIntents,
4095        >,
4096        update: F,
4097    ) -> Result<bamboo_server_tools::FabricCommitSnapshot, AppError>
4098    where
4099        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
4100    {
4101        self.update_cluster_fabric_credentials_guarded(
4102            expected_revision,
4103            node_intents,
4104            Some(node_id),
4105            update,
4106        )
4107        .await
4108    }
4109
4110    async fn update_cluster_fabric_credentials_guarded<F>(
4111        &self,
4112        expected_revision: u64,
4113        node_intents: std::collections::BTreeMap<
4114            String,
4115            bamboo_config::ClusterNodeCredentialIntents,
4116        >,
4117        required_stopped_node: Option<String>,
4118        update: F,
4119    ) -> Result<bamboo_server_tools::FabricCommitSnapshot, AppError>
4120    where
4121        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
4122    {
4123        let config_io_lock = self.config_io_lock.clone();
4124        let config = self.config.clone();
4125        let app_data_dir = self.app_data_dir.clone();
4126        let account_sink = self.account_sink.clone();
4127        let config_facade = self.config_facade.clone();
4128        let deployed_registry = self.fabric_deployer.registry();
4129        let transaction = tokio::spawn(async move {
4130            let _io = config_io_lock.lock().await;
4131            if let Some(node_id) = required_stopped_node.as_deref() {
4132                let deployed = deployed_registry.lock().await;
4133                if deployed.contains_key(&bamboo_server_tools::registry_keys::node_key(node_id)) {
4134                    return Err(AppError::BadRequest(format!(
4135                        "node '{node_id}' is deployed; stop it before deleting it"
4136                    )));
4137                }
4138            }
4139            let facade = config_facade.as_ref().ok_or_else(|| {
4140                AppError::BadRequest(
4141                    "cluster mutations require the modular configuration facade".to_string(),
4142                )
4143            })?;
4144            let mut candidate = {
4145                let current = config.read().await;
4146                reject_if_recovery_pending(&current)?;
4147                current.clone()
4148            };
4149            // A process-local runtime can lag a commit from another process.
4150            // Build the request from the exact durable generation named by the
4151            // client. `None` deliberately selects the secret-free read: using
4152            // `Some(expected_revision)` would hydrate first and prevent an
4153            // explicit Clear/Replace from repairing corrupt ciphertext. The
4154            // returned status metadata is still crypto-validated. An untouched
4155            // corrupt active ref retains the established commit-first contract:
4156            // a metadata change can durably commit and then report its exact
4157            // runtime materialization error (covered by
4158            // `changed_cluster_commit_publishes_secret_free_runtime_before_materialization_error`).
4159            // The compound writer rechecks the same revision before committing,
4160            // so an intervening winner becomes a conflict.
4161            let snapshot_dir = app_data_dir.clone();
4162            let exact = tokio::task::spawn_blocking(move || {
4163                bamboo_config::read_exact_cluster_fabric_snapshot(&snapshot_dir, None)
4164            })
4165            .await
4166            .map_err(|error| {
4167                AppError::InternalError(anyhow::anyhow!("cluster snapshot task failed: {error}"))
4168            })?
4169            .map_err(|error| match error {
4170                ConfigStoreError::Conflict { expected, actual } => {
4171                    AppError::ConfigConflict { expected, actual }
4172                }
4173                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
4174                ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(
4175                    anyhow::anyhow!("configuration commit outcome is indeterminate: {message}"),
4176                ),
4177                ConfigStoreError::Io(error) => AppError::StorageError(error),
4178                ConfigStoreError::Json(_) => {
4179                    AppError::BadRequest("configuration document is invalid".to_string())
4180                }
4181                ConfigStoreError::Watch(error) => {
4182                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
4183                }
4184            })?;
4185            if exact.section.revision != expected_revision {
4186                return Err(AppError::ConfigConflict {
4187                    expected: expected_revision,
4188                    actual: exact.section.revision,
4189                });
4190            }
4191            if exact.section.status != SectionStatus::Healthy
4192                || exact.section.source_kind != SectionSourceKind::File
4193                || exact.credential_health.status == SectionStatus::Degraded
4194            {
4195                return Err(AppError::BadRequest(
4196                    "revision-bound cluster mutations require healthy primary authorities"
4197                        .to_string(),
4198                ));
4199            }
4200            candidate.cluster_fabric = exact.cluster_fabric;
4201            update(&mut candidate)?;
4202            let transaction_dir = app_data_dir.clone();
4203            let commit_facade = facade.clone();
4204            let (mut candidate, commit) = tokio::task::spawn_blocking(move || {
4205                let commit =
4206                    bamboo_config::persist_cluster_fabric_credential_transaction_with_adoption(
4207                        &transaction_dir,
4208                        &mut candidate,
4209                        &node_intents,
4210                        expected_revision,
4211                        commit_facade.as_ref(),
4212                        |_, _| {
4213                            #[cfg(test)]
4214                            run_cluster_after_commit_before_adoption_test_hook(
4215                                &transaction_dir,
4216                                expected_revision,
4217                            );
4218                        },
4219                    )?;
4220                Ok::<_, ConfigStoreError>((candidate, commit))
4221            })
4222            .await
4223            .map_err(|error| {
4224                AppError::InternalError(anyhow::anyhow!(
4225                    "cluster credential transaction task failed: {error}"
4226                ))
4227            })?
4228            .map_err(|error| match error {
4229                ConfigStoreError::Conflict { expected, actual } => {
4230                    AppError::ConfigConflict { expected, actual }
4231                }
4232                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
4233                ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(
4234                    anyhow::anyhow!("configuration commit outcome is indeterminate: {message}"),
4235                ),
4236                ConfigStoreError::Io(error) => AppError::StorageError(error),
4237                ConfigStoreError::Json(_) => {
4238                    AppError::BadRequest("configuration document is invalid".to_string())
4239                }
4240                ConfigStoreError::Watch(error) => {
4241                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
4242                }
4243            })?;
4244            let bamboo_config::ClusterFabricTransactionCommit {
4245                revision,
4246                adoption,
4247                credential_adoption,
4248                committed_recovery,
4249                runtime,
4250            } = commit;
4251            let runtime = match runtime {
4252                Ok(bamboo_config::ClusterFabricRuntimeSnapshot {
4253                    cluster_fabric,
4254                    credential_statuses,
4255                    credential_health,
4256                }) => {
4257                    candidate.cluster_fabric = cluster_fabric;
4258                    Ok((credential_statuses, credential_health))
4259                }
4260                Err(error) if revision == expected_revision => {
4261                    return Err(AppError::InternalError(anyhow::anyhow!(
4262                        "cluster configuration at revision {revision} could not materialize its exact runtime credentials: {error}"
4263                    )));
4264                }
4265                Err(error) => {
4266                    candidate.clear_cluster_runtime_credentials();
4267                    Err(error)
4268                }
4269            };
4270            *config.write().await = candidate.clone();
4271            let event = match adoption {
4272                Some(Ok(event)) => Some(event),
4273                Some(Err(error)) => {
4274                    return Err(AppError::InternalError(anyhow::anyhow!(
4275                        "cluster configuration committed at revision {} but process adoption failed: {error}",
4276                        revision
4277                    )));
4278                }
4279                None if revision == expected_revision => None,
4280                None => {
4281                    return Err(AppError::InternalError(anyhow::anyhow!(
4282                        "cluster configuration committed at revision {} without a process adoption result",
4283                        revision
4284                    )));
4285                }
4286            };
4287            let section = facade
4288                .registry()
4289                .envelope_value(SectionId::ClusterFabric)
4290                .map_err(|error| {
4291                    AppError::InternalError(anyhow::anyhow!(
4292                        "committed cluster section envelope is unavailable: {error}"
4293                    ))
4294                })?;
4295            if section.revision != revision {
4296                return Err(AppError::InternalError(anyhow::anyhow!(
4297                    "cluster configuration committed at revision {} but facade retained revision {}",
4298                    revision,
4299                    section.revision
4300                )));
4301            }
4302            if let Some(event) = event.as_ref() {
4303                publish_registry_event(&account_sink, event);
4304            }
4305            if let Err(error) = committed_recovery {
4306                return Err(AppError::InternalError(anyhow::anyhow!(
4307                    "cluster configuration committed at revision {revision} but transaction recovery failed: {error}"
4308                )));
4309            }
4310            if let Some(Err(error)) = credential_adoption {
4311                return Err(AppError::InternalError(anyhow::anyhow!(
4312                    "cluster configuration committed at revision {revision} but credential facade adoption failed: {error}"
4313                )));
4314            }
4315            let (credential_statuses, credential_health) = runtime.map_err(|error| {
4316                AppError::InternalError(anyhow::anyhow!(
4317                    "cluster configuration committed at revision {revision} but could not materialize its exact runtime credentials: {error}"
4318                ))
4319            })?;
4320            Ok::<_, AppError>(bamboo_server_tools::FabricCommitSnapshot {
4321                config: candidate,
4322                section,
4323                credential_statuses,
4324                credential_health,
4325            })
4326        });
4327        transaction.await.map_err(|error| {
4328            AppError::InternalError(anyhow::anyhow!(
4329                "cluster credential transaction task failed: {error}"
4330            ))
4331        })?
4332    }
4333
4334    /// Persist proxy authentication through the isolated credential store and
4335    /// publish the detached runtime candidate only after the exact transaction
4336    /// has durably committed.
4337    pub async fn update_proxy_auth_credential(
4338        &self,
4339        auth: Option<bamboo_config::ProxyAuth>,
4340        expected_revision: u64,
4341        effects: ConfigUpdateEffects,
4342    ) -> Result<
4343        (
4344            Config,
4345            u64,
4346            bamboo_config::CredentialStatus,
4347            bamboo_config::CredentialStoreHealth,
4348            Option<bamboo_config::SectionEnvelope<Value>>,
4349        ),
4350        AppError,
4351    > {
4352        self.update_core_with_proxy_credential(expected_revision, effects, move |candidate| {
4353            candidate.proxy_auth = auth;
4354        })
4355        .await
4356    }
4357
4358    async fn update_core_with_proxy_credential<F>(
4359        &self,
4360        expected_revision: u64,
4361        effects: ConfigUpdateEffects,
4362        update: F,
4363    ) -> Result<
4364        (
4365            Config,
4366            u64,
4367            bamboo_config::CredentialStatus,
4368            bamboo_config::CredentialStoreHealth,
4369            Option<bamboo_config::SectionEnvelope<Value>>,
4370        ),
4371        AppError,
4372    >
4373    where
4374        F: FnOnce(&mut Config) + Send + 'static,
4375    {
4376        let config_io_lock = self.config_io_lock.clone();
4377        let config = self.config.clone();
4378        let app_data_dir = self.app_data_dir.clone();
4379        let credential_store = self.credential_store.clone();
4380        let provider_registry = self.provider_registry.clone();
4381        let provider = self.provider.clone();
4382        let mcp_manager = self.mcp_manager.clone();
4383        let config_facade = self.config_facade.clone();
4384        let account_sink = self.account_sink.clone();
4385
4386        // This task owns the mutation after dispatch. Dropping the request's
4387        // JoinHandle does not cancel it, so the blocking durable transaction,
4388        // live publication, and runtime convergence complete as one serialized
4389        // operation even when the caller disconnects.
4390        let transaction = tokio::spawn(async move {
4391            let _io = config_io_lock.lock().await;
4392            let live_base = {
4393                let cfg = config.read().await;
4394                reject_if_recovery_pending(&cfg)?;
4395                cfg.clone()
4396            };
4397            let mut candidate = live_base.clone();
4398            if config_facade.is_some() {
4399                install_exact_credential_section_mutation_base(
4400                    app_data_dir.clone(),
4401                    SectionId::Core,
4402                    expected_revision,
4403                    &mut candidate,
4404                )
4405                .await?;
4406            }
4407            update(&mut candidate);
4408            if config_facade.is_none() {
4409                candidate.assign_connect_platform_ids();
4410                candidate.refresh_encrypted_secrets().map_err(|error| {
4411                    AppError::InternalError(anyhow::anyhow!(
4412                        "Failed to refresh encrypted secrets: {error}"
4413                    ))
4414                })?;
4415            }
4416            let transaction_dir = app_data_dir.clone();
4417            let status_reference =
4418                candidate
4419                    .proxy_auth_credential_ref
4420                    .clone()
4421                    .unwrap_or_else(|| {
4422                        bamboo_config::CredentialRef::parse("proxy.default.auth")
4423                            .expect("canonical proxy credential reference is valid")
4424                    });
4425            let commit_facade = config_facade.clone();
4426            let (candidate, revision, reference, commit) =
4427                tokio::task::spawn_blocking(move || {
4428                if let Some(facade) = commit_facade {
4429                    let commit =
4430                        bamboo_config::persist_proxy_auth_credential_transaction_at_revision_with_adoption(
4431                            &transaction_dir,
4432                            &mut candidate,
4433                            expected_revision,
4434                            facade.as_ref(),
4435                        )?;
4436                    let revision = commit.revision;
4437                    Ok::<_, ConfigStoreError>((
4438                        candidate,
4439                        revision,
4440                        status_reference,
4441                        Some(commit),
4442                    ))
4443                } else {
4444                    let revision =
4445                        bamboo_config::persist_proxy_auth_credential_transaction_at_revision(
4446                        &transaction_dir,
4447                        &mut candidate,
4448                        expected_revision,
4449                    )?;
4450                    Ok((
4451                        load_committed_effective_config(&transaction_dir)?,
4452                        revision,
4453                        status_reference,
4454                        None,
4455                    ))
4456                }
4457            })
4458            .await
4459            .map_err(|error| {
4460                AppError::InternalError(anyhow::anyhow!(
4461                    "proxy credential transaction task failed: {error}"
4462                ))
4463            })?
4464            .map_err(|error| match error {
4465                ConfigStoreError::Conflict { expected, actual } => {
4466                    AppError::ConfigConflict { expected, actual }
4467                }
4468                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
4469                ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(
4470                    anyhow::anyhow!("configuration commit outcome is indeterminate: {message}"),
4471                ),
4472                ConfigStoreError::Io(error) => AppError::StorageError(error),
4473                ConfigStoreError::Json(_) => {
4474                    AppError::BadRequest("configuration document is invalid".to_string())
4475                }
4476                ConfigStoreError::Watch(error) => {
4477                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
4478                }
4479            })?;
4480            let (published, installed) = match commit {
4481                Some(commit) => {
4482                    let mut published = live_base;
4483                    let installed = install_credential_section_commit(commit, &mut published)
4484                        .map_err(|error| {
4485                            AppError::InternalError(anyhow::anyhow!(
4486                                "proxy process adoption failed: {error}"
4487                            ))
4488                        })?;
4489                    (published, Some(installed))
4490                }
4491                None => (candidate, None),
4492            };
4493            let section = installed
4494                .as_ref()
4495                .and_then(|installed| installed.section.clone());
4496
4497            // No fallible metadata read occurs before publication. Once the
4498            // transaction commits, a response error can no longer leave live
4499            // config behind its durable credential/config pair.
4500            published.publish_env_vars();
4501            *config.write().await = published.clone();
4502
4503            if let Some(installed) = installed.as_ref() {
4504                publish_exact_facade_events(&account_sink, &installed.events)?;
4505            }
4506
4507            if effects.reload_provider {
4508                match bamboo_llm::ProviderRegistry::from_config(&published, app_data_dir.clone())
4509                    .await
4510                {
4511                    Ok(candidate_registry) => {
4512                        if let Some(candidate_provider) = candidate_registry.get_default() {
4513                            let mut live_provider = provider.write().await;
4514                            provider_registry.replace_with(candidate_registry);
4515                            *live_provider = candidate_provider;
4516                        } else {
4517                            tracing::warn!(
4518                                "proxy auth committed but provider reload had no default provider"
4519                            );
4520                        }
4521                    }
4522                    Err(error) => {
4523                        tracing::warn!(
4524                            error = %error,
4525                            "proxy auth committed but provider reload failed"
4526                        );
4527                    }
4528                }
4529            }
4530
4531            if effects.reconcile_mcp {
4532                mcp_manager.reconcile_from_config(&published.mcp).await;
4533            }
4534
4535            let (status, health) = if let Some(installed) = installed {
4536                (
4537                    installed.metadata.status(&reference),
4538                    installed.metadata.credential_health,
4539                )
4540            } else {
4541                credential_store
4542                    .status_with_health(&reference)
4543                    .map_err(|error| match error {
4544                        ConfigStoreError::Conflict { expected, actual } => {
4545                            AppError::ConfigConflict { expected, actual }
4546                        }
4547                        ConfigStoreError::Validation(_)
4548                        | ConfigStoreError::CommitIndeterminate(_)
4549                        | ConfigStoreError::Json(_) => AppError::InternalError(anyhow::anyhow!(
4550                            "credential store validation failed"
4551                        )),
4552                        ConfigStoreError::Io(error) => AppError::StorageError(error),
4553                        ConfigStoreError::Watch(error) => AppError::InternalError(anyhow::anyhow!(
4554                            "configuration watch failed: {error}"
4555                        )),
4556                    })?
4557            };
4558            Ok::<_, AppError>((published, revision, status, health, section))
4559        });
4560        transaction.await.map_err(|error| {
4561            AppError::InternalError(anyhow::anyhow!(
4562                "proxy credential mutation task failed: {error}"
4563            ))
4564        })?
4565    }
4566
4567    /// Replace the full config (used for JSON merge endpoints).
4568    pub async fn replace_config(
4569        &self,
4570        mut new_config: Config,
4571        effects: ConfigUpdateEffects,
4572    ) -> Result<Config, AppError> {
4573        // Backfill any missing connect.platforms id (#496) up front, before
4574        // any of the clones below are taken, so the in-memory config, the
4575        // disk-persisted snapshot, and the value this call returns to the
4576        // caller (the settings-merge HTTP response) all agree on the same
4577        // ids — mirrors the `update_config` treatment above.
4578        if self.config_facade.is_none() {
4579            new_config.assign_connect_platform_ids();
4580            // Keep ciphertext in sync with plaintext on legacy layouts. In a
4581            // modular layout the one owned section transaction is responsible
4582            // for its credentials and unrelated runtime fields must not move.
4583            new_config.refresh_encrypted_secrets().map_err(|e| {
4584                AppError::InternalError(anyhow::anyhow!("Failed to refresh encrypted secrets: {e}"))
4585            })?;
4586        }
4587
4588        let io = self.config_io_lock.clone().lock_owned().await;
4589        restore_authoritative_cluster_fabric(self.config_facade.as_ref(), &mut new_config);
4590        let (was_off, live_base) = {
4591            let cfg = self.config.read().await;
4592            // Same guard as `update_config` (#153): a full-config replace must
4593            // not silently blow away an unconfirmed recovery either.
4594            reject_if_recovery_pending(&cfg)?;
4595            (cfg.plugin_trust.enforcement_is_off(), cfg.clone())
4596        };
4597        let config = self.config.clone();
4598        let app_data_dir = self.app_data_dir.clone();
4599        let config_facade = self.config_facade.clone();
4600        let account_sink = self.account_sink.clone();
4601        let provider_registry = self.provider_registry.clone();
4602        let provider = self.provider.clone();
4603        let mcp_manager = self.mcp_manager.clone();
4604        let transaction = tokio::spawn(async move {
4605            // Same #126 serialization as update_config: mutate + persist under
4606            // the config-IO lock so a reload can't interleave. Provider/MCP
4607            // effects also remain serialized so an older replacement cannot
4608            // overwrite a later writer's runtime after an async suspension.
4609            let new_config = {
4610                let _io = io;
4611                let commit = Self::persist_config_snapshot(
4612                    app_data_dir.clone(),
4613                    config_facade.clone(),
4614                    new_config.clone(),
4615                )
4616                .await?;
4617                let mut published = if commit.is_some() {
4618                    live_base
4619                } else {
4620                    new_config
4621                };
4622                let events = match commit {
4623                    Some(commit) => {
4624                        install_facade_config_commit(commit, &mut published).map_err(|error| {
4625                            AppError::InternalError(anyhow::anyhow!(
4626                                "failed to install committed configuration section: {error}"
4627                            ))
4628                        })?
4629                    }
4630                    None => Vec::new(),
4631                };
4632                let enforcement_newly_off = !was_off && published.plugin_trust.enforcement_is_off();
4633                published.publish_env_vars();
4634                *config.write().await = published.clone();
4635                // Same live signal as `update_config` — a full-config replace
4636                // that transitions plugin_trust.enforcement into `Off` warns.
4637                if enforcement_newly_off {
4638                    warn_plugin_trust_enforcement_off();
4639                }
4640                publish_exact_facade_events(&account_sink, &events)?;
4641                Self::apply_config_effects_owned(
4642                    published.clone(),
4643                    effects,
4644                    app_data_dir,
4645                    config,
4646                    provider_registry,
4647                    provider,
4648                    mcp_manager,
4649                )
4650                .await?;
4651                published
4652            };
4653            Ok::<_, AppError>(new_config)
4654        });
4655        transaction.await.map_err(|error| {
4656            AppError::InternalError(anyhow::anyhow!(
4657                "config replacement transaction task failed: {error}"
4658            ))
4659        })?
4660    }
4661
4662    async fn apply_config_effects_owned(
4663        new_config: Config,
4664        effects: ConfigUpdateEffects,
4665        app_data_dir: PathBuf,
4666        config: Arc<RwLock<Config>>,
4667        provider_registry: Arc<bamboo_llm::ProviderRegistry>,
4668        provider: Arc<RwLock<Arc<dyn LLMProvider>>>,
4669        mcp_manager: Arc<McpServerManager>,
4670    ) -> Result<(), AppError> {
4671        if effects.reload_provider {
4672            // The caller retains config_io_lock through provider construction
4673            // and publication, so this current live snapshot cannot be
4674            // superseded while an awaited runtime build is in flight.
4675            let live_config = config.read().await.clone();
4676            let candidate_registry =
4677                bamboo_llm::ProviderRegistry::from_config(&live_config, app_data_dir.clone())
4678                    .await
4679                    .map_err(|e| {
4680                        AppError::InternalError(anyhow::anyhow!(
4681                            "Failed to reload provider after updating config: {e}"
4682                        ))
4683                    })?;
4684            let default_provider_name = candidate_registry.default_provider_name();
4685            let candidate_provider = candidate_registry.get_default().ok_or_else(|| {
4686                let message = if live_config.has_provider_instances() {
4687                    format!(
4688                        "Default provider instance '{}' is not available or failed to initialize",
4689                        default_provider_name
4690                    )
4691                } else {
4692                    format!(
4693                        "Provider '{}' is not available or failed to initialize",
4694                        live_config.provider
4695                    )
4696                };
4697                AppError::InternalError(anyhow::anyhow!(
4698                    "Failed to reload provider after updating config: {}",
4699                    bamboo_llm::LLMError::Auth(message)
4700                ))
4701            })?;
4702            #[cfg(test)]
4703            run_generic_before_provider_publish_test_hook(&app_data_dir);
4704            let mut live_provider = provider.write().await;
4705            provider_registry.replace_with(candidate_registry);
4706            *live_provider = candidate_provider;
4707            tracing::info!(
4708                default_provider = %default_provider_name,
4709                "Provider reloaded successfully"
4710            );
4711        }
4712
4713        if effects.reconcile_mcp {
4714            mcp_manager.reconcile_from_config(&new_config.mcp).await;
4715        }
4716
4717        Ok(())
4718    }
4719
4720    /// Resolve a pending config-corruption recovery (#153; see
4721    /// [`bamboo_config::ConfigRecoveryStatus`]).
4722    ///
4723    /// - `accept = true`: confirms the recovery and persists it to
4724    ///   `config.json` in the same step ([`Config::confirm_recovery_and_save_to_dir`]),
4725    ///   then clears the pending flag — the config is no longer "pending
4726    ///   confirmation" once this returns `Ok`.
4727    /// - `accept = false`: a no-op that leaves everything untouched — disk,
4728    ///   in-memory config, and the pending flag are all left exactly as they
4729    ///   were. `config.json` stays refused-to-write (see `save_to_dir`) until
4730    ///   either a later `accept = true` call or the user hand-fixes
4731    ///   `config.json` and the process reloads/restarts.
4732    ///
4733    /// Errors with [`AppError::BadRequest`] if there's no pending recovery to
4734    /// resolve.
4735    pub async fn confirm_config_recovery(&self, accept: bool) -> Result<Config, AppError> {
4736        let _io = self.config_io_lock.lock().await;
4737
4738        if !accept {
4739            let cfg = self.config.read().await;
4740            return match cfg.recovery_status() {
4741                Some(_) => Ok(cfg.clone()),
4742                None => Err(AppError::BadRequest(
4743                    "No pending config-corruption recovery to resolve".to_string(),
4744                )),
4745            };
4746        }
4747
4748        let mut candidate = {
4749            let cfg = self.config.read().await;
4750            match cfg.recovery_status() {
4751                Some(_) => cfg.clone(),
4752                None => {
4753                    return Err(AppError::BadRequest(
4754                        "No pending config-corruption recovery to resolve".to_string(),
4755                    ))
4756                }
4757            }
4758        };
4759
4760        let data_dir = self.app_data_dir.clone();
4761        candidate = tokio::task::spawn_blocking(move || {
4762            candidate
4763                .confirm_recovery_and_save_to_dir(data_dir)
4764                .map(|_| candidate)
4765        })
4766        .await
4767        .map_err(|e| {
4768            AppError::InternalError(anyhow::anyhow!("Config recovery-confirm task failed: {e}"))
4769        })?
4770        .map_err(|e| {
4771            AppError::InternalError(anyhow::anyhow!("Failed to save recovered config: {e}"))
4772        })?;
4773
4774        {
4775            let mut cfg = self.config.write().await;
4776            *cfg = candidate.clone();
4777            cfg.publish_env_vars();
4778        }
4779
4780        Ok(candidate)
4781    }
4782}
4783
4784/// Short-circuit config-mutating entrypoints while a config-corruption
4785/// recovery is pending confirmation (#153): `save_to_dir` would refuse the
4786/// disk write anyway, but rejecting here — before any in-memory mutation
4787/// runs — keeps the in-memory config frozen at exactly the recovered state
4788/// instead of drifting further out of sync with what's actually on disk.
4789/// Resolve the pending recovery via `AppState::confirm_config_recovery`
4790/// first.
4791fn reject_if_recovery_pending(cfg: &Config) -> Result<(), AppError> {
4792    if let Some(status) = cfg.recovery_status() {
4793        if !status.confirmed {
4794            return Err(AppError::ConfigRecoveryPending(format!(
4795                "config.json was recovered from corruption ({:?}) and is awaiting \
4796                 confirmation; confirm or reject the recovery (see /bamboo/config/recovery-status \
4797                 and /bamboo/config/recovery/confirm) before changing settings",
4798                status.source
4799            )));
4800        }
4801    }
4802    Ok(())
4803}
4804
4805/// The prominent warning emitted whenever `plugin_trust.enforcement` is (or
4806/// becomes) `Off` — that setting silently downgrades EVERY subsequent `url`
4807/// plugin install/update to skip the host allowlist, signature, and
4808/// checksum-required-by-default layers, with no per-install flag needed (see
4809/// `crate::plugin_source`'s module docs). Factored into one function so the
4810/// boot-time signal (`AppState::new`) and the live-apply signal
4811/// (`update_config`/`replace_config`, covering the HTTP `config set` / PATCH
4812/// paths) emit the EXACT same message — no drift, and no trigger can flip
4813/// enforcement off silently.
4814pub(crate) fn warn_plugin_trust_enforcement_off() {
4815    tracing::warn!(
4816        "plugin_trust.enforcement is OFF — plugin installs from ANY URL are accepted \
4817         without host/signature/checksum verification (config.json plugin_trust.enforcement)"
4818    );
4819}
4820
4821#[cfg(test)]
4822mod live_reload_tests {
4823    use super::*;
4824    use bamboo_agent_core::{Message, ToolSchema};
4825    use bamboo_llm::{LLMError, LLMStream};
4826    use bamboo_mcp::{McpServerConfig, ReconnectConfig, StdioConfig};
4827
4828    struct WorkingProvider;
4829
4830    fn stop_config_watcher(state: &mut AppState) {
4831        state.config_watcher.stop.store(true, Ordering::Relaxed);
4832        if let Some(task) = state.config_watcher.apply_task.take() {
4833            task.abort();
4834        }
4835        if let Some(task) = state.config_watcher.watcher_task.take() {
4836            task.join().unwrap();
4837        }
4838    }
4839
4840    async fn insert_registry_worker(state: &AppState, key: String, worker_id: &str) {
4841        #[cfg(unix)]
4842        let child = tokio::process::Command::new("/bin/sleep")
4843            .arg("30")
4844            .spawn()
4845            .unwrap();
4846        #[cfg(windows)]
4847        let child = tokio::process::Command::new("cmd")
4848            .args(["/C", "timeout", "/T", "30", "/NOBREAK"])
4849            .spawn()
4850            .unwrap();
4851        state.fabric_deployer.registry().lock().await.insert(
4852            key,
4853            bamboo_server_tools::Deployed {
4854                env: "test".to_string(),
4855                handle: bamboo_broker::DeployedAgent::from_parts(worker_id, child, None),
4856            },
4857        );
4858    }
4859
4860    fn disabled_mcp_config(id: &str) -> McpConfig {
4861        McpConfig {
4862            version: 1,
4863            servers: vec![McpServerConfig {
4864                id: id.to_string(),
4865                name: None,
4866                enabled: false,
4867                transport: TransportConfig::Stdio(StdioConfig {
4868                    command: "unused-disabled-command".to_string(),
4869                    args: vec![],
4870                    cwd: None,
4871                    env: std::collections::HashMap::new(),
4872                    env_encrypted: std::collections::HashMap::new(),
4873                    env_credential_refs: std::collections::HashMap::new(),
4874                    startup_timeout_ms: 100,
4875                }),
4876                request_timeout_ms: 100,
4877                healthcheck_interval_ms: 100,
4878                reconnect: ReconnectConfig::default(),
4879                allowed_tools: vec![],
4880                denied_tools: vec![],
4881            }],
4882        }
4883    }
4884
4885    fn mcp_document_bytes(revision: u64, config: &McpConfig) -> Vec<u8> {
4886        serde_json::to_vec_pretty(&serde_json::json!({
4887            "schema_version": 1,
4888            "revision": revision,
4889            "data": config,
4890        }))
4891        .unwrap()
4892    }
4893
4894    #[test]
4895    fn touched_shared_mcp_refs_stage_replacements_and_preserve_surviving_clears() {
4896        let shared =
4897            bamboo_config::CredentialRef::parse("mcp.shared.env_token".to_string()).unwrap();
4898        let mut current = disabled_mcp_config("first");
4899        let mut second = current.servers[0].clone();
4900        second.id = "second".to_string();
4901        current.servers.push(second);
4902        for server in &mut current.servers {
4903            let TransportConfig::Stdio(stdio) = &mut server.transport else {
4904                unreachable!()
4905            };
4906            stdio
4907                .env
4908                .insert("TOKEN".to_string(), "old-shared-secret".to_string());
4909            stdio
4910                .env_credential_refs
4911                .insert("TOKEN".to_string(), shared.as_str().to_string());
4912        }
4913        let touched = BTreeSet::from([shared]);
4914
4915        let mut replace = current.clone();
4916        for server in &mut replace.servers {
4917            let TransportConfig::Stdio(stdio) = &mut server.transport else {
4918                unreachable!()
4919            };
4920            stdio.env.get_mut("TOKEN").unwrap().clear();
4921        }
4922        let TransportConfig::Stdio(first) = &mut replace.servers[0].transport else {
4923            unreachable!()
4924        };
4925        first
4926            .env
4927            .insert("TOKEN".to_string(), "new-shared-secret".to_string());
4928        materialize_mcp_touched_replacements(&mut replace, &touched).unwrap();
4929        retain_mcp_credentials(&current, &mut replace, &touched);
4930        for server in &replace.servers {
4931            let TransportConfig::Stdio(stdio) = &server.transport else {
4932                unreachable!()
4933            };
4934            assert_eq!(stdio.env["TOKEN"], "new-shared-secret");
4935        }
4936
4937        let mut clear_one = current.clone();
4938        for server in &mut clear_one.servers {
4939            let TransportConfig::Stdio(stdio) = &mut server.transport else {
4940                unreachable!()
4941            };
4942            stdio.env.get_mut("TOKEN").unwrap().clear();
4943        }
4944        let TransportConfig::Stdio(first) = &mut clear_one.servers[0].transport else {
4945            unreachable!()
4946        };
4947        first.env.remove("TOKEN");
4948        first.env_credential_refs.remove("TOKEN");
4949        materialize_mcp_touched_replacements(&mut clear_one, &touched).unwrap();
4950        retain_mcp_credentials(&current, &mut clear_one, &touched);
4951        let TransportConfig::Stdio(first) = &clear_one.servers[0].transport else {
4952            unreachable!()
4953        };
4954        assert!(!first.env.contains_key("TOKEN"));
4955        assert!(!first.env_credential_refs.contains_key("TOKEN"));
4956        let TransportConfig::Stdio(second) = &clear_one.servers[1].transport else {
4957            unreachable!()
4958        };
4959        assert_eq!(second.env["TOKEN"], "old-shared-secret");
4960        assert_eq!(second.env_credential_refs["TOKEN"], "mcp.shared.env_token");
4961
4962        let header_ref =
4963            bamboo_config::CredentialRef::parse("mcp.shared.header_token".to_string()).unwrap();
4964        let current_http = McpConfig {
4965            version: 1,
4966            servers: vec![McpServerConfig {
4967                id: "http".to_string(),
4968                name: None,
4969                enabled: false,
4970                transport: TransportConfig::Sse(bamboo_mcp::SseConfig {
4971                    url: "https://example.test/sse".to_string(),
4972                    headers: vec![bamboo_mcp::HeaderConfig {
4973                        name: "Authorization".to_string(),
4974                        value: "old-header-secret".to_string(),
4975                        value_encrypted: None,
4976                        credential_ref: Some(header_ref.as_str().to_string()),
4977                    }],
4978                    connect_timeout_ms: 100,
4979                }),
4980                request_timeout_ms: 100,
4981                healthcheck_interval_ms: 100,
4982                reconnect: ReconnectConfig::default(),
4983                allowed_tools: vec![],
4984                denied_tools: vec![],
4985            }],
4986        };
4987        let mut delete_all_headers = current_http.clone();
4988        let TransportConfig::Sse(candidate) = &mut delete_all_headers.servers[0].transport else {
4989            unreachable!()
4990        };
4991        candidate.headers.clear();
4992        let touched = BTreeSet::from([header_ref]);
4993        materialize_mcp_touched_replacements(&mut delete_all_headers, &touched).unwrap();
4994        retain_mcp_credentials(&current_http, &mut delete_all_headers, &touched);
4995        let TransportConfig::Sse(candidate) = &delete_all_headers.servers[0].transport else {
4996            unreachable!()
4997        };
4998        assert!(candidate.headers.is_empty());
4999    }
5000
5001    fn install_unrecoverable_pending_provider_migration(dir: &Path) {
5002        let transaction_id = uuid::Uuid::new_v4().to_string();
5003        std::fs::write(
5004            dir.join("config.json"),
5005            br#"{"providers":{"openai":{"model":"root-lkg"}}}"#,
5006        )
5007        .unwrap();
5008        std::fs::write(
5009            dir.join("providers.json"),
5010            br#"{"schema_version":1,"revision":2,"data":{"openai":{"model":"partial-must-not-load","credential_ref":"provider.openai.api_key"}}}"#,
5011        )
5012        .unwrap();
5013        std::fs::write(
5014            dir.join("config-credential-migration.json"),
5015            serde_json::to_vec_pretty(&serde_json::json!({
5016                "version": 1,
5017                "transaction_id": transaction_id.clone(),
5018                "stage_dir": format!(".config-credential-stage-v1-{transaction_id}"),
5019                "state": "pending",
5020                "files": [
5021                    {
5022                        "name": "credentials.json",
5023                        "staged_name": "credentials.json",
5024                        "sha256": "0".repeat(64),
5025                        "sensitive": true
5026                    },
5027                    {
5028                        "name": "providers.json",
5029                        "staged_name": "providers.json",
5030                        "sha256": "1".repeat(64),
5031                        "original_sha256": "2".repeat(64),
5032                        "migration_generation": 2,
5033                        "sensitive": false
5034                    }
5035                ]
5036            }))
5037            .unwrap(),
5038        )
5039        .unwrap();
5040    }
5041
5042    async fn wait_for_mcp_health(
5043        state: &AppState,
5044        status: SectionStatus,
5045        minimum_revision: u64,
5046    ) -> ConfigLiveHealth {
5047        match tokio::time::timeout(Duration::from_secs(4), async {
5048            loop {
5049                let health = state
5050                    .mcp_config_live_health
5051                    .read()
5052                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5053                    .clone();
5054                if health.status == status && health.revision >= minimum_revision {
5055                    break health;
5056                }
5057                tokio::time::sleep(Duration::from_millis(20)).await;
5058            }
5059        })
5060        .await
5061        {
5062            Ok(health) => health,
5063            Err(_) => panic!(
5064                "MCP health transition timed out: {:?}",
5065                state
5066                    .mcp_config_live_health
5067                    .read()
5068                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5069                    .clone()
5070            ),
5071        }
5072    }
5073
5074    async fn next_config_event(
5075        feed: &mut tokio::sync::broadcast::Receiver<Arc<bamboo_engine::events::ChangeEvent>>,
5076        expected_section: &str,
5077    ) -> AgentEvent {
5078        tokio::time::timeout(Duration::from_secs(3), async {
5079            loop {
5080                let envelope = feed.recv().await.expect("account feed remains open");
5081                match &envelope.event {
5082                    AgentEvent::ConfigChanged { section, .. }
5083                    | AgentEvent::ConfigInvalid { section, .. }
5084                    | AgentEvent::ConfigRecovered { section, .. }
5085                        if section == expected_section =>
5086                    {
5087                        break envelope.event.clone();
5088                    }
5089                    _ => {}
5090                }
5091            }
5092        })
5093        .await
5094        .expect("config event timed out")
5095    }
5096
5097    async fn next_mcp_config_event(
5098        feed: &mut tokio::sync::broadcast::Receiver<Arc<bamboo_engine::events::ChangeEvent>>,
5099    ) -> AgentEvent {
5100        next_config_event(feed, "mcp").await
5101    }
5102
5103    #[tokio::test]
5104    async fn compatibility_update_cannot_reintroduce_an_unrevisioned_cluster_mutation() {
5105        let _key = bamboo_config::encryption::set_test_encryption_key([0x70; 32]);
5106        let dir = tempfile::tempdir().unwrap();
5107        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5108        state
5109            .update_cluster_fabric_credentials(
5110                0,
5111                std::collections::BTreeMap::from([(
5112                    "owned-node".to_string(),
5113                    bamboo_config::ClusterNodeCredentialIntents::clear_all(),
5114                )]),
5115                |config| {
5116                    config.cluster_fabric.nodes.push(bamboo_config::Node {
5117                        id: "owned-node".to_string(),
5118                        label: "revisioned-label".to_string(),
5119                        placement: bamboo_config::NodePlacement::Local,
5120                        trust_level: bamboo_config::TrustLevel::Trusted,
5121                        deploy: bamboo_config::DeployProfile::default(),
5122                        state: None,
5123                        enabled: true,
5124                    });
5125                    Ok(())
5126                },
5127            )
5128            .await
5129            .unwrap();
5130        let cluster_path = dir.path().join("cluster-fabric.json");
5131        let cluster_before = std::fs::read(&cluster_path).unwrap();
5132
5133        let updated = state
5134            .update_config(
5135                |config| {
5136                    config.server.port = 21_000;
5137                    config.cluster_fabric.node_mut("owned-node").unwrap().label =
5138                        "unrevisioned-label".to_string();
5139                    Ok(())
5140                },
5141                ConfigUpdateEffects::default(),
5142            )
5143            .await
5144            .unwrap();
5145
5146        assert_eq!(updated.server.port, 21_000);
5147        assert_eq!(
5148            updated.cluster_fabric.node("owned-node").unwrap().label,
5149            "revisioned-label"
5150        );
5151        assert_eq!(
5152            state
5153                .config_facade
5154                .as_ref()
5155                .unwrap()
5156                .registry()
5157                .cluster_fabric
5158                .snapshot()
5159                .revision,
5160            1
5161        );
5162        assert_eq!(std::fs::read(cluster_path).unwrap(), cluster_before);
5163    }
5164
5165    #[tokio::test]
5166    async fn stopped_watcher_compatibility_writers_install_only_their_owned_section() {
5167        let _key = bamboo_config::encryption::set_test_encryption_key([0x72; 32]);
5168        let dir = tempfile::tempdir().unwrap();
5169        let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5170        state
5171            .update_cluster_fabric_credentials(
5172                0,
5173                BTreeMap::from([(
5174                    "shared-node".to_string(),
5175                    bamboo_config::ClusterNodeCredentialIntents::clear_all(),
5176                )]),
5177                |config| {
5178                    config.cluster_fabric.nodes.push(bamboo_config::Node {
5179                        id: "shared-node".to_string(),
5180                        label: "generation-one".to_string(),
5181                        placement: bamboo_config::NodePlacement::Local,
5182                        trust_level: bamboo_config::TrustLevel::Trusted,
5183                        deploy: bamboo_config::DeployProfile::default(),
5184                        state: None,
5185                        enabled: true,
5186                    });
5187                    Ok(())
5188                },
5189            )
5190            .await
5191            .unwrap();
5192        stop_config_watcher(&mut state);
5193        let state = Arc::new(state);
5194
5195        let external = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
5196        let mut external_candidate = external.effective_config();
5197        external_candidate
5198            .cluster_fabric
5199            .node_mut("shared-node")
5200            .unwrap()
5201            .label = "external-generation-two".to_string();
5202        assert_eq!(
5203            bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
5204                dir.path(),
5205                &mut external_candidate,
5206                &BTreeMap::new(),
5207                1,
5208            )
5209            .unwrap(),
5210            2
5211        );
5212        let cluster_path = dir.path().join("cluster-fabric.json");
5213        let cluster_r2 = std::fs::read(&cluster_path).unwrap();
5214        assert_eq!(
5215            state
5216                .config_facade
5217                .as_ref()
5218                .unwrap()
5219                .registry()
5220                .cluster_fabric
5221                .snapshot()
5222                .revision,
5223            1
5224        );
5225        assert_eq!(
5226            state
5227                .config
5228                .read()
5229                .await
5230                .cluster_fabric
5231                .node("shared-node")
5232                .unwrap()
5233                .label,
5234            "generation-one"
5235        );
5236
5237        let baseline_seq = state.account_sink.latest_seq();
5238        let mut core_feed = state.account_sink.subscribe();
5239        let mut cluster_feed = state.account_sink.subscribe();
5240        let stale_runtime = state.config.read().await;
5241        let updating = {
5242            let state = state.clone();
5243            tokio::spawn(async move {
5244                state
5245                    .update_config(
5246                        |config| {
5247                            config.server.port = 23_332;
5248                            Ok(())
5249                        },
5250                        ConfigUpdateEffects::default(),
5251                    )
5252                    .await
5253            })
5254        };
5255        assert!(
5256            tokio::time::timeout(
5257                Duration::from_millis(100),
5258                next_config_event(&mut core_feed, "core"),
5259            )
5260            .await
5261            .is_err(),
5262            "core event became observable while the old AppState snapshot was held"
5263        );
5264        assert_ne!(stale_runtime.server.port, 23_332);
5265        drop(stale_runtime);
5266        let published = updating.await.unwrap().unwrap();
5267        assert!(matches!(
5268            next_config_event(&mut core_feed, "core").await,
5269            AgentEvent::ConfigChanged { section, .. } if section == "core"
5270        ));
5271        assert_eq!(state.config.read().await.server.port, 23_332);
5272        assert!(
5273            tokio::time::timeout(
5274                Duration::from_millis(300),
5275                next_config_event(&mut cluster_feed, "cluster-fabric"),
5276            )
5277            .await
5278            .is_err(),
5279            "an unrelated compatibility update published a cluster event"
5280        );
5281        assert_eq!(
5282            published.cluster_fabric.node("shared-node").unwrap().label,
5283            "generation-one"
5284        );
5285        assert_eq!(std::fs::read(&cluster_path).unwrap(), cluster_r2);
5286        assert_eq!(
5287            state
5288                .config_facade
5289                .as_ref()
5290                .unwrap()
5291                .registry()
5292                .cluster_fabric
5293                .snapshot()
5294                .revision,
5295            1,
5296            "an unrelated compatibility update must not catch up cluster"
5297        );
5298        assert_eq!(
5299            state
5300                .config
5301                .read()
5302                .await
5303                .cluster_fabric
5304                .node("shared-node")
5305                .unwrap()
5306                .label,
5307            "generation-one"
5308        );
5309        tokio::time::sleep(Duration::from_millis(100)).await;
5310        let events = bamboo_engine::events::journal::read_since(
5311            state.account_sink.events_dir(),
5312            baseline_seq,
5313        )
5314        .unwrap();
5315        assert_eq!(
5316            events
5317                .iter()
5318                .filter(|event| matches!(
5319                    &event.event,
5320                    AgentEvent::ConfigChanged { section, .. } if section == "core"
5321                ))
5322                .count(),
5323            1
5324        );
5325        assert!(!events.iter().any(|event| matches!(
5326            &event.event,
5327            AgentEvent::ConfigChanged { section, .. } if section == "cluster-fabric"
5328        )));
5329
5330        let external = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
5331        let mut external_candidate = external.effective_config();
5332        external_candidate
5333            .cluster_fabric
5334            .node_mut("shared-node")
5335            .unwrap()
5336            .label = "external-generation-three".to_string();
5337        assert_eq!(
5338            bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
5339                dir.path(),
5340                &mut external_candidate,
5341                &BTreeMap::new(),
5342                2,
5343            )
5344            .unwrap(),
5345            3
5346        );
5347        let cluster_r3 = std::fs::read(&cluster_path).unwrap();
5348        assert_eq!(
5349            state
5350                .config_facade
5351                .as_ref()
5352                .unwrap()
5353                .registry()
5354                .cluster_fabric
5355                .snapshot()
5356                .revision,
5357            1,
5358            "the stopped watcher must remain stale before replace_config"
5359        );
5360
5361        let mut replacement = state.config.read().await.clone();
5362        replacement.server.port = 23_333;
5363        let baseline_seq = state.account_sink.latest_seq();
5364        let mut core_feed = state.account_sink.subscribe();
5365        let mut cluster_feed = state.account_sink.subscribe();
5366        let stale_runtime = state.config.read().await;
5367        let replacing = {
5368            let state = state.clone();
5369            tokio::spawn(async move {
5370                state
5371                    .replace_config(replacement, ConfigUpdateEffects::default())
5372                    .await
5373            })
5374        };
5375        assert!(
5376            tokio::time::timeout(
5377                Duration::from_millis(100),
5378                next_config_event(&mut core_feed, "core"),
5379            )
5380            .await
5381            .is_err(),
5382            "replacement event became observable while the old AppState snapshot was held"
5383        );
5384        assert_ne!(stale_runtime.server.port, 23_333);
5385        drop(stale_runtime);
5386        let published = replacing.await.unwrap().unwrap();
5387        assert!(matches!(
5388            next_config_event(&mut core_feed, "core").await,
5389            AgentEvent::ConfigChanged { section, .. } if section == "core"
5390        ));
5391        assert_eq!(state.config.read().await.server.port, 23_333);
5392        assert!(
5393            tokio::time::timeout(
5394                Duration::from_millis(300),
5395                next_config_event(&mut cluster_feed, "cluster-fabric"),
5396            )
5397            .await
5398            .is_err(),
5399            "an unrelated compatibility replacement published a cluster event"
5400        );
5401        assert_eq!(published.server.port, 23_333);
5402        assert_eq!(
5403            published.cluster_fabric.node("shared-node").unwrap().label,
5404            "generation-one"
5405        );
5406        assert_eq!(std::fs::read(&cluster_path).unwrap(), cluster_r3);
5407        assert_eq!(
5408            state
5409                .config_facade
5410                .as_ref()
5411                .unwrap()
5412                .registry()
5413                .cluster_fabric
5414                .snapshot()
5415                .revision,
5416            1
5417        );
5418        assert_eq!(
5419            state
5420                .config
5421                .read()
5422                .await
5423                .cluster_fabric
5424                .node("shared-node")
5425                .unwrap()
5426                .label,
5427            "generation-one"
5428        );
5429        tokio::time::sleep(Duration::from_millis(100)).await;
5430        let events = bamboo_engine::events::journal::read_since(
5431            state.account_sink.events_dir(),
5432            baseline_seq,
5433        )
5434        .unwrap();
5435        assert_eq!(
5436            events
5437                .iter()
5438                .filter(|event| matches!(
5439                    &event.event,
5440                    AgentEvent::ConfigChanged { section, .. } if section == "core"
5441                ))
5442                .count(),
5443            1
5444        );
5445        assert!(!events.iter().any(|event| matches!(
5446            &event.event,
5447            AgentEvent::ConfigChanged { section, .. } if section == "cluster-fabric"
5448        )));
5449    }
5450
5451    #[tokio::test]
5452    async fn exact_notification_publication_installs_only_its_owned_runtime_section() {
5453        let dir = tempfile::tempdir().unwrap();
5454        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5455        {
5456            let mut live = state.config.write().await;
5457            live.connect
5458                .platforms
5459                .push(bamboo_config::ConnectPlatformConfig {
5460                    id: None,
5461                    project_id: None,
5462                    platform_type: "runtime-sentinel".to_string(),
5463                    token: None,
5464                    token_encrypted: None,
5465                    token_credential_ref: None,
5466                    token_configured: false,
5467                    app_id: None,
5468                    app_secret: None,
5469                    app_secret_encrypted: None,
5470                    app_secret_credential_ref: None,
5471                    app_secret_configured: false,
5472                    domain: None,
5473                    allow_from: Vec::new(),
5474                    admin_from: Vec::new(),
5475                });
5476            live.providers_mut().openai = Some(bamboo_config::OpenAIConfig {
5477                api_key: "runtime-provider-sentinel".to_string(),
5478                ..Default::default()
5479            });
5480        }
5481        let connect_before = std::fs::read(dir.path().join("connect.json")).unwrap();
5482        let (published, revision, _, section) = state
5483            .update_notification_credentials(0, BTreeSet::new(), false, |candidate| {
5484                candidate.notifications.ntfy.enabled = true;
5485                candidate.notifications.ntfy.topic = "owned-notification".to_string();
5486                // Deliberately perturb unrelated runtime-only state. Modular
5487                // publication must discard both changes after committing only
5488                // the Notifications section.
5489                candidate.assign_connect_platform_ids();
5490                candidate
5491                    .providers_mut()
5492                    .openai
5493                    .as_mut()
5494                    .unwrap()
5495                    .api_key
5496                    .clear();
5497                Ok(())
5498            })
5499            .await
5500            .unwrap();
5501
5502        assert_eq!(revision, 1);
5503        assert_eq!(section.unwrap().revision, 1);
5504        assert_eq!(published.notifications.ntfy.topic, "owned-notification");
5505        assert!(published.connect.platforms[0].id.is_none());
5506        assert_eq!(
5507            published.providers().openai.as_ref().unwrap().api_key,
5508            "runtime-provider-sentinel"
5509        );
5510        let live = state.config.read().await;
5511        assert!(live.connect.platforms[0].id.is_none());
5512        assert_eq!(
5513            live.providers().openai.as_ref().unwrap().api_key,
5514            "runtime-provider-sentinel"
5515        );
5516        drop(live);
5517        assert_eq!(
5518            std::fs::read(dir.path().join("connect.json")).unwrap(),
5519            connect_before
5520        );
5521        assert_eq!(
5522            bamboo_config::ConfigFacade::open(dir.path())
5523                .unwrap()
5524                .registry()
5525                .connect
5526                .snapshot()
5527                .revision,
5528            0
5529        );
5530    }
5531
5532    #[tokio::test]
5533    async fn generic_update_cannot_forge_exact_core_credential_binding() {
5534        let dir = tempfile::tempdir().unwrap();
5535        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5536        let core_before = std::fs::read(dir.path().join("core.json")).unwrap();
5537        let error = state
5538            .update_config(
5539                |candidate| {
5540                    candidate.proxy_auth_credential_ref =
5541                        Some(bamboo_config::CredentialRef::parse("proxy.default.auth").unwrap());
5542                    Ok(())
5543                },
5544                ConfigUpdateEffects::default(),
5545            )
5546            .await
5547            .unwrap_err();
5548        assert!(matches!(error, AppError::BadRequest(_)));
5549        assert!(error.to_string().contains("credential bindings"));
5550        assert_eq!(
5551            std::fs::read(dir.path().join("core.json")).unwrap(),
5552            core_before
5553        );
5554        assert!(state
5555            .config
5556            .read()
5557            .await
5558            .proxy_auth_credential_ref
5559            .is_none());
5560        assert_eq!(
5561            state
5562                .config_facade
5563                .as_ref()
5564                .unwrap()
5565                .registry()
5566                .core
5567                .snapshot()
5568                .revision,
5569            0
5570        );
5571    }
5572
5573    #[tokio::test]
5574    async fn env_credential_commit_installs_owned_runtime_before_exact_events() {
5575        let _key = bamboo_config::encryption::set_test_encryption_key([0x73; 32]);
5576        let dir = tempfile::tempdir().unwrap();
5577        let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5578        state
5579            .update_cluster_fabric_credentials(
5580                0,
5581                BTreeMap::from([(
5582                    "shared-node".to_string(),
5583                    bamboo_config::ClusterNodeCredentialIntents::clear_all(),
5584                )]),
5585                |config| {
5586                    config.cluster_fabric.nodes.push(bamboo_config::Node {
5587                        id: "shared-node".to_string(),
5588                        label: "generation-one".to_string(),
5589                        placement: bamboo_config::NodePlacement::Local,
5590                        trust_level: bamboo_config::TrustLevel::Trusted,
5591                        deploy: bamboo_config::DeployProfile::default(),
5592                        state: None,
5593                        enabled: true,
5594                    });
5595                    Ok(())
5596                },
5597            )
5598            .await
5599            .unwrap();
5600        stop_config_watcher(&mut state);
5601        let state = Arc::new(state);
5602
5603        let external = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
5604        let mut external_candidate = external.effective_config();
5605        external_candidate
5606            .cluster_fabric
5607            .node_mut("shared-node")
5608            .unwrap()
5609            .label = "external-generation-two".to_string();
5610        bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
5611            dir.path(),
5612            &mut external_candidate,
5613            &BTreeMap::new(),
5614            1,
5615        )
5616        .unwrap();
5617        let cluster_path = dir.path().join("cluster-fabric.json");
5618        let cluster_r2 = std::fs::read(&cluster_path).unwrap();
5619        let expected_revision = state
5620            .config_facade
5621            .as_ref()
5622            .unwrap()
5623            .registry()
5624            .env
5625            .snapshot()
5626            .revision;
5627
5628        let (reached_tx, reached_rx) = std::sync::mpsc::channel();
5629        let (release_tx, release_rx) = std::sync::mpsc::channel();
5630        set_credential_after_commit_before_live_test_hook(dir.path(), SectionId::Env, move || {
5631            reached_tx.send(()).unwrap();
5632            release_rx.recv().unwrap();
5633        });
5634        let baseline_seq = state.account_sink.latest_seq();
5635        let mut credential_feed = state.account_sink.subscribe();
5636        let mut env_feed = state.account_sink.subscribe();
5637        let mut cluster_feed = state.account_sink.subscribe();
5638        let updating = {
5639            let state = state.clone();
5640            tokio::spawn(async move {
5641                state
5642                    .update_env_var_credentials(
5643                        expected_revision,
5644                        BTreeSet::from(["TOKEN".to_string()]),
5645                        false,
5646                        |config| {
5647                            config.env_vars.push(bamboo_config::EnvVarEntry {
5648                                name: "TOKEN".to_string(),
5649                                value: "exact-secret".to_string(),
5650                                secret: true,
5651                                value_encrypted: None,
5652                                credential_ref: None,
5653                                configured: true,
5654                                description: None,
5655                            });
5656                            Ok(())
5657                        },
5658                    )
5659                    .await
5660            })
5661        };
5662        tokio::task::spawn_blocking(move || reached_rx.recv().unwrap())
5663            .await
5664            .unwrap();
5665        let stale_runtime = state.config.read().await;
5666        release_tx.send(()).unwrap();
5667        for (feed, section) in [
5668            (&mut credential_feed, "credentials"),
5669            (&mut env_feed, "env"),
5670            (&mut cluster_feed, "cluster-fabric"),
5671        ] {
5672            assert!(
5673                tokio::time::timeout(Duration::from_millis(100), next_config_event(feed, section),)
5674                    .await
5675                    .is_err(),
5676                "{section} event became observable before the owned runtime install"
5677            );
5678        }
5679        assert!(
5680            stale_runtime
5681                .env_vars
5682                .iter()
5683                .all(|entry| entry.name != "TOKEN"),
5684            "the held runtime must still be the pre-commit env generation"
5685        );
5686        assert_eq!(
5687            stale_runtime
5688                .cluster_fabric
5689                .node("shared-node")
5690                .unwrap()
5691                .label,
5692            "generation-one"
5693        );
5694        drop(stale_runtime);
5695
5696        let (published, revision, _, _) = updating.await.unwrap().unwrap();
5697        assert!(revision > expected_revision);
5698        assert!(published
5699            .env_vars
5700            .iter()
5701            .any(|entry| entry.name == "TOKEN" && entry.value == "exact-secret"));
5702        assert_eq!(
5703            published.cluster_fabric.node("shared-node").unwrap().label,
5704            "generation-one"
5705        );
5706        assert!(matches!(
5707            next_config_event(&mut credential_feed, "credentials").await,
5708            AgentEvent::ConfigChanged { section, .. } if section == "credentials"
5709        ));
5710        assert!(matches!(
5711            next_config_event(&mut env_feed, "env").await,
5712            AgentEvent::ConfigChanged { section, .. } if section == "env"
5713        ));
5714        assert!(tokio::time::timeout(
5715            Duration::from_millis(300),
5716            next_config_event(&mut cluster_feed, "cluster-fabric"),
5717        )
5718        .await
5719        .is_err());
5720        assert_eq!(std::fs::read(cluster_path).unwrap(), cluster_r2);
5721        assert_eq!(
5722            state
5723                .config_facade
5724                .as_ref()
5725                .unwrap()
5726                .registry()
5727                .cluster_fabric
5728                .snapshot()
5729                .revision,
5730            1
5731        );
5732        tokio::time::sleep(Duration::from_millis(100)).await;
5733        let events = bamboo_engine::events::journal::read_since(
5734            state.account_sink.events_dir(),
5735            baseline_seq,
5736        )
5737        .unwrap();
5738        assert_eq!(
5739            events
5740                .iter()
5741                .filter(|event| matches!(
5742                    &event.event,
5743                    AgentEvent::ConfigChanged { section, .. } if section == "credentials"
5744                ))
5745                .count(),
5746            1
5747        );
5748        assert_eq!(
5749            events
5750                .iter()
5751                .filter(|event| matches!(
5752                    &event.event,
5753                    AgentEvent::ConfigChanged { section, .. } if section == "env"
5754                ))
5755                .count(),
5756            1
5757        );
5758        assert!(!events.iter().any(|event| matches!(
5759            &event.event,
5760            AgentEvent::ConfigChanged { section, .. } if section == "cluster-fabric"
5761        )));
5762    }
5763
5764    #[tokio::test]
5765    async fn env_mutation_returns_its_captured_envelope_after_a_later_section_commit() {
5766        let _key = bamboo_config::encryption::set_test_encryption_key([0x74; 32]);
5767        let dir = tempfile::tempdir().unwrap();
5768        let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5769        stop_config_watcher(&mut state);
5770        let state = Arc::new(state);
5771
5772        let (reached_tx, reached_rx) = std::sync::mpsc::channel();
5773        let (release_tx, release_rx) = std::sync::mpsc::channel();
5774        set_credential_after_commit_before_live_test_hook(dir.path(), SectionId::Env, move || {
5775            reached_tx.send(()).unwrap();
5776            release_rx.recv().unwrap();
5777        });
5778        let updating = {
5779            let state = state.clone();
5780            tokio::spawn(async move {
5781                state
5782                    .update_env_var_credentials(
5783                        0,
5784                        BTreeSet::from(["TOKEN".to_string()]),
5785                        false,
5786                        |config| {
5787                            config.env_vars.push(bamboo_config::EnvVarEntry {
5788                                name: "TOKEN".to_string(),
5789                                value: "first-secret".to_string(),
5790                                secret: true,
5791                                value_encrypted: None,
5792                                credential_ref: None,
5793                                configured: true,
5794                                description: Some("first generation".to_string()),
5795                            });
5796                            Ok(())
5797                        },
5798                    )
5799                    .await
5800            })
5801        };
5802        tokio::task::spawn_blocking(move || reached_rx.recv().unwrap())
5803            .await
5804            .unwrap();
5805
5806        let external_dir = dir.path().to_path_buf();
5807        let process_facade = state.config_facade.clone().unwrap();
5808        let later = tokio::task::spawn_blocking(move || {
5809            let external = bamboo_config::ConfigFacade::open(&external_dir).unwrap();
5810            let mut candidate = external.effective_config();
5811            candidate.env_vars[0].description = Some("later generation".to_string());
5812            bamboo_config::persist_env_var_credential_transaction_at_revision_with_adoption(
5813                &external_dir,
5814                &mut candidate,
5815                &BTreeSet::from(["TOKEN".to_string()]),
5816                1,
5817                process_facade.as_ref(),
5818            )
5819            .unwrap()
5820        })
5821        .await
5822        .unwrap();
5823        assert_eq!(later.revision, 2);
5824        assert_eq!(later.section.unwrap().revision, 2);
5825        release_tx.send(()).unwrap();
5826
5827        let (_, revision, _, section) = updating.await.unwrap().unwrap();
5828        let section = section.expect("modular mutation returns its exact section");
5829        assert_eq!(revision, 1);
5830        assert_eq!(section.revision, 1);
5831        assert_eq!(section.data[0]["description"], "first generation");
5832        assert_eq!(
5833            state
5834                .config_facade
5835                .as_ref()
5836                .unwrap()
5837                .registry()
5838                .env
5839                .snapshot()
5840                .revision,
5841            2,
5842            "the process facade advanced, but the response retained its own commit"
5843        );
5844    }
5845
5846    #[tokio::test]
5847    async fn cluster_commit_installs_runtime_before_one_authoritative_event() {
5848        let _key = bamboo_config::encryption::set_test_encryption_key([0x71; 32]);
5849        let dir = tempfile::tempdir().unwrap();
5850        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5851        let revision = state
5852            .config_facade
5853            .as_ref()
5854            .unwrap()
5855            .registry()
5856            .cluster_fabric
5857            .snapshot()
5858            .revision;
5859        let baseline_seq = state.account_sink.latest_seq();
5860        let mut feed = state.account_sink.subscribe();
5861        let runtime = state.config.clone();
5862        let observer = tokio::spawn(async move {
5863            tokio::time::timeout(Duration::from_secs(3), async move {
5864                loop {
5865                    let event = feed.recv().await.unwrap();
5866                    match &event.event {
5867                        AgentEvent::ConfigChanged { section, .. } if section == "credentials" => {
5868                            panic!("cluster mutation published an internal credential event")
5869                        }
5870                        AgentEvent::ConfigChanged { section, revision }
5871                            if section == "cluster-fabric" =>
5872                        {
5873                            assert!(
5874                                runtime
5875                                    .read()
5876                                    .await
5877                                    .cluster_fabric
5878                                    .node("event-node")
5879                                    .is_some(),
5880                                "event observer saw the old runtime snapshot"
5881                            );
5882                            return *revision;
5883                        }
5884                        _ => {}
5885                    }
5886                }
5887            })
5888            .await
5889            .expect("cluster event timed out")
5890        });
5891
5892        let node = bamboo_config::Node {
5893            id: "event-node".to_string(),
5894            label: "event-node".to_string(),
5895            placement: bamboo_config::NodePlacement::Local,
5896            trust_level: bamboo_config::TrustLevel::Trusted,
5897            deploy: bamboo_config::DeployProfile::default(),
5898            state: None,
5899            enabled: true,
5900        };
5901        let committed = state
5902            .update_cluster_fabric_credentials(
5903                revision,
5904                BTreeMap::from([(
5905                    "event-node".to_string(),
5906                    bamboo_config::ClusterNodeCredentialIntents::clear_all(),
5907                )]),
5908                move |config| {
5909                    config.cluster_fabric.nodes.push(node);
5910                    Ok(())
5911                },
5912            )
5913            .await
5914            .unwrap();
5915        let committed = committed.section.revision;
5916        assert_eq!(committed, revision + 1);
5917        assert_eq!(observer.await.unwrap(), committed);
5918
5919        tokio::time::sleep(Duration::from_millis(100)).await;
5920        let events = bamboo_engine::events::journal::read_since(
5921            state.account_sink.events_dir(),
5922            baseline_seq,
5923        )
5924        .unwrap();
5925        let cluster_events = events
5926            .iter()
5927            .filter(|event| {
5928                matches!(
5929                    &event.event,
5930                    AgentEvent::ConfigChanged { section, revision: event_revision }
5931                        if section == "cluster-fabric" && *event_revision == committed
5932                )
5933            })
5934            .count();
5935        let credential_events = events
5936            .iter()
5937            .filter(|event| {
5938                matches!(
5939                    &event.event,
5940                    AgentEvent::ConfigChanged { section, .. } if section == "credentials"
5941                )
5942            })
5943            .count();
5944        assert_eq!(cluster_events, 1);
5945        assert_eq!(credential_events, 0);
5946    }
5947
5948    #[tokio::test]
5949    async fn stale_process_cluster_candidate_rebases_on_exact_durable_client_generation() {
5950        let dir = tempfile::tempdir().unwrap();
5951        let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5952        state
5953            .update_cluster_fabric_credentials(
5954                0,
5955                BTreeMap::from([(
5956                    "shared-node".to_string(),
5957                    bamboo_config::ClusterNodeCredentialIntents::clear_all(),
5958                )]),
5959                |config| {
5960                    config.cluster_fabric.nodes.push(bamboo_config::Node {
5961                        id: "shared-node".to_string(),
5962                        label: "generation-one".to_string(),
5963                        placement: bamboo_config::NodePlacement::Local,
5964                        trust_level: bamboo_config::TrustLevel::Trusted,
5965                        deploy: bamboo_config::DeployProfile::default(),
5966                        state: None,
5967                        enabled: true,
5968                    });
5969                    Ok(())
5970                },
5971            )
5972            .await
5973            .unwrap();
5974        stop_config_watcher(&mut state);
5975
5976        let external = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
5977        let mut external_candidate = external.effective_config();
5978        external_candidate
5979            .cluster_fabric
5980            .clusters
5981            .push(bamboo_config::Cluster {
5982                name: "external-cluster".to_string(),
5983                description: Some("durable-r2-field".to_string()),
5984                node_ids: vec!["shared-node".to_string()],
5985            });
5986        assert_eq!(
5987            bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
5988                dir.path(),
5989                &mut external_candidate,
5990                &BTreeMap::new(),
5991                1,
5992            )
5993            .unwrap(),
5994            2
5995        );
5996        assert_eq!(
5997            state
5998                .config_facade
5999                .as_ref()
6000                .unwrap()
6001                .registry()
6002                .cluster_fabric
6003                .snapshot()
6004                .revision,
6005            1
6006        );
6007        assert!(
6008            state
6009                .config
6010                .read()
6011                .await
6012                .cluster_fabric
6013                .cluster("external-cluster")
6014                .is_none(),
6015            "the process runtime is intentionally stale at r1"
6016        );
6017
6018        let committed = state
6019            .update_cluster_fabric_credentials(2, BTreeMap::new(), |config| {
6020                config.cluster_fabric.node_mut("shared-node").unwrap().label =
6021                    "client-r3-edit".to_string();
6022                Ok(())
6023            })
6024            .await
6025            .unwrap();
6026        assert_eq!(committed.section.revision, 3);
6027        assert_eq!(
6028            committed
6029                .config
6030                .cluster_fabric
6031                .node("shared-node")
6032                .unwrap()
6033                .label,
6034            "client-r3-edit"
6035        );
6036        assert_eq!(
6037            committed
6038                .config
6039                .cluster_fabric
6040                .cluster("external-cluster")
6041                .unwrap()
6042                .description
6043                .as_deref(),
6044            Some("durable-r2-field")
6045        );
6046        assert_eq!(
6047            state
6048                .config_facade
6049                .as_ref()
6050                .unwrap()
6051                .registry()
6052                .cluster_fabric
6053                .snapshot()
6054                .revision,
6055            3,
6056            "compound adoption must safely catch the stale r1 facade up to r3"
6057        );
6058
6059        let runtime_before_conflict = state.config.read().await.cluster_fabric.clone();
6060        let conflict = state
6061            .update_cluster_fabric_credentials(2, BTreeMap::new(), |config| {
6062                config.cluster_fabric.nodes.clear();
6063                config.cluster_fabric.clusters.clear();
6064                Ok(())
6065            })
6066            .await;
6067        assert!(matches!(
6068            conflict,
6069            Err(AppError::ConfigConflict {
6070                expected: 2,
6071                actual: 3
6072            })
6073        ));
6074        assert_eq!(
6075            state.config.read().await.cluster_fabric,
6076            runtime_before_conflict,
6077            "a durable CAS conflict must not overwrite the process runtime"
6078        );
6079    }
6080
6081    #[tokio::test]
6082    async fn stale_process_cluster_noop_catches_up_exact_durable_generation() {
6083        let dir = tempfile::tempdir().unwrap();
6084        let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6085        stop_config_watcher(&mut state);
6086
6087        let external = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
6088        let mut external_candidate = external.effective_config();
6089        external_candidate
6090            .cluster_fabric
6091            .nodes
6092            .push(bamboo_config::Node {
6093                id: "external-node".to_string(),
6094                label: "external-r1".to_string(),
6095                placement: bamboo_config::NodePlacement::Local,
6096                trust_level: bamboo_config::TrustLevel::Trusted,
6097                deploy: bamboo_config::DeployProfile::default(),
6098                state: None,
6099                enabled: true,
6100            });
6101        assert_eq!(
6102            bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
6103                dir.path(),
6104                &mut external_candidate,
6105                &BTreeMap::from([(
6106                    "external-node".to_string(),
6107                    bamboo_config::ClusterNodeCredentialIntents::clear_all(),
6108                )]),
6109                0,
6110            )
6111            .unwrap(),
6112            1
6113        );
6114        assert_eq!(
6115            state
6116                .config_facade
6117                .as_ref()
6118                .unwrap()
6119                .registry()
6120                .cluster_fabric
6121                .snapshot()
6122                .revision,
6123            0
6124        );
6125        let baseline_seq = state.account_sink.latest_seq();
6126
6127        let committed = state
6128            .update_cluster_fabric_credentials(1, BTreeMap::new(), |_| Ok(()))
6129            .await
6130            .unwrap();
6131        assert_eq!(committed.section.revision, 1);
6132        assert_eq!(
6133            committed
6134                .config
6135                .cluster_fabric
6136                .node("external-node")
6137                .unwrap()
6138                .label,
6139            "external-r1"
6140        );
6141        assert_eq!(
6142            state
6143                .config_facade
6144                .as_ref()
6145                .unwrap()
6146                .registry()
6147                .cluster_fabric
6148                .snapshot()
6149                .revision,
6150            1
6151        );
6152        assert_eq!(
6153            state
6154                .config
6155                .read()
6156                .await
6157                .cluster_fabric
6158                .node("external-node")
6159                .unwrap()
6160                .label,
6161            "external-r1"
6162        );
6163
6164        tokio::time::sleep(Duration::from_millis(100)).await;
6165        let events = bamboo_engine::events::journal::read_since(
6166            state.account_sink.events_dir(),
6167            baseline_seq,
6168        )
6169        .unwrap();
6170        let revisions = events
6171            .iter()
6172            .filter_map(|event| match &event.event {
6173                AgentEvent::ConfigChanged { section, revision } if section == "cluster-fabric" => {
6174                    Some(*revision)
6175                }
6176                _ => None,
6177            })
6178            .collect::<Vec<_>>();
6179        assert_eq!(revisions, vec![1]);
6180    }
6181
6182    #[tokio::test]
6183    async fn stale_process_cluster_reset_noop_catches_up_exact_durable_generation() {
6184        let dir = tempfile::tempdir().unwrap();
6185        let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6186        stop_config_watcher(&mut state);
6187
6188        let external = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
6189        let mut external_candidate = external.effective_config();
6190        external_candidate
6191            .cluster_fabric
6192            .nodes
6193            .push(bamboo_config::Node {
6194                id: "reset-node".to_string(),
6195                label: "reset-node".to_string(),
6196                placement: bamboo_config::NodePlacement::Local,
6197                trust_level: bamboo_config::TrustLevel::Trusted,
6198                deploy: bamboo_config::DeployProfile::default(),
6199                state: None,
6200                enabled: true,
6201            });
6202        assert_eq!(
6203            bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
6204                dir.path(),
6205                &mut external_candidate,
6206                &BTreeMap::from([(
6207                    "reset-node".to_string(),
6208                    bamboo_config::ClusterNodeCredentialIntents::clear_all(),
6209                )]),
6210                0,
6211            )
6212            .unwrap(),
6213            1
6214        );
6215        let reset_facade = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
6216        let mut reset_candidate = reset_facade.effective_config();
6217        reset_candidate.cluster_fabric = bamboo_config::ClusterFabricConfig::default();
6218        let external_reset = bamboo_config::persist_cluster_fabric_reset_at_revision_with_adoption(
6219            dir.path(),
6220            &mut reset_candidate,
6221            1,
6222            &reset_facade,
6223            |_, _| {},
6224        )
6225        .unwrap();
6226        assert_eq!(external_reset.revision, 2);
6227        assert_eq!(
6228            state
6229                .config_facade
6230                .as_ref()
6231                .unwrap()
6232                .registry()
6233                .cluster_fabric
6234                .snapshot()
6235                .revision,
6236            0
6237        );
6238        let baseline_seq = state.account_sink.latest_seq();
6239
6240        let committed = state
6241            .reset_credential_backed_section(SectionId::ClusterFabric, 2)
6242            .await
6243            .unwrap();
6244        let CredentialBackedResetCommit::Cluster(committed) = committed else {
6245            panic!("cluster reset must return its exact snapshot")
6246        };
6247        assert_eq!(committed.section.revision, 2);
6248        assert!(committed.config.cluster_fabric.nodes.is_empty());
6249        assert_eq!(
6250            state
6251                .config_facade
6252                .as_ref()
6253                .unwrap()
6254                .registry()
6255                .cluster_fabric
6256                .snapshot()
6257                .revision,
6258            2
6259        );
6260        assert!(state.config.read().await.cluster_fabric.nodes.is_empty());
6261
6262        tokio::time::sleep(Duration::from_millis(100)).await;
6263        let events = bamboo_engine::events::journal::read_since(
6264            state.account_sink.events_dir(),
6265            baseline_seq,
6266        )
6267        .unwrap();
6268        let revisions = events
6269            .iter()
6270            .filter_map(|event| match &event.event {
6271                AgentEvent::ConfigChanged { section, revision } if section == "cluster-fabric" => {
6272                    Some(*revision)
6273                }
6274                _ => None,
6275            })
6276            .collect::<Vec<_>>();
6277        assert_eq!(revisions, vec![2]);
6278    }
6279
6280    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6281    async fn generic_events_follow_serialized_local_commit_order() {
6282        let dir = tempfile::tempdir().unwrap();
6283        let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6284        stop_config_watcher(&mut state);
6285        let state = Arc::new(state);
6286        let baseline_seq = state.account_sink.latest_seq();
6287        let (reached_tx, reached_rx) = std::sync::mpsc::sync_channel(0);
6288        let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
6289        set_generic_before_event_test_hook(dir.path(), move || {
6290            reached_tx.send(()).unwrap();
6291            release_rx.recv().unwrap();
6292        });
6293
6294        let first = {
6295            let state = state.clone();
6296            tokio::spawn(async move {
6297                state
6298                    .update_config(
6299                        |config| {
6300                            config.server.port = 22_231;
6301                            Ok(())
6302                        },
6303                        ConfigUpdateEffects::default(),
6304                    )
6305                    .await
6306            })
6307        };
6308        tokio::task::spawn_blocking(move || reached_rx.recv().unwrap())
6309            .await
6310            .unwrap();
6311        let second = {
6312            let state = state.clone();
6313            tokio::spawn(async move {
6314                state
6315                    .update_config(
6316                        |config| {
6317                            config.server.port = 22_232;
6318                            Ok(())
6319                        },
6320                        ConfigUpdateEffects::default(),
6321                    )
6322                    .await
6323            })
6324        };
6325        tokio::time::sleep(Duration::from_millis(100)).await;
6326        assert!(
6327            !second.is_finished(),
6328            "the later writer must remain behind the first writer's event"
6329        );
6330        let events = bamboo_engine::events::journal::read_since(
6331            state.account_sink.events_dir(),
6332            baseline_seq,
6333        )
6334        .unwrap();
6335        assert!(
6336            events.iter().all(|event| !matches!(
6337                &event.event,
6338                AgentEvent::ConfigChanged { section, .. } if section == "core"
6339            )),
6340            "neither local commit can publish while the first owns config_io_lock"
6341        );
6342
6343        release_tx.send(()).unwrap();
6344        assert_eq!(first.await.unwrap().unwrap().server.port, 22_231);
6345        assert_eq!(second.await.unwrap().unwrap().server.port, 22_232);
6346        tokio::time::timeout(Duration::from_secs(3), async {
6347            loop {
6348                let events = bamboo_engine::events::journal::read_since(
6349                    state.account_sink.events_dir(),
6350                    baseline_seq,
6351                )
6352                .unwrap();
6353                let revisions = events
6354                    .iter()
6355                    .filter_map(|event| match &event.event {
6356                        AgentEvent::ConfigChanged { section, revision } if section == "core" => {
6357                            Some(*revision)
6358                        }
6359                        _ => None,
6360                    })
6361                    .collect::<Vec<_>>();
6362                if revisions.len() == 2 {
6363                    break revisions;
6364                }
6365                tokio::time::sleep(Duration::from_millis(20)).await;
6366            }
6367        })
6368        .await
6369        .map(|revisions| assert_eq!(revisions, vec![1, 2]))
6370        .expect("both serialized core events must reach the journal");
6371    }
6372
6373    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6374    async fn generic_runtime_effects_finish_before_later_config_writer() {
6375        let dir = tempfile::tempdir().unwrap();
6376        let script = dir.path().join("mcp-fixture.py");
6377        std::fs::write(
6378            &script,
6379            r#"import json
6380import sys
6381
6382for line in sys.stdin:
6383    request = json.loads(line)
6384    request_id = request.get("id")
6385    if request_id is None:
6386        continue
6387    if request.get("method") == "server/discover":
6388        print(json.dumps({
6389            "jsonrpc": "2.0",
6390            "id": request_id,
6391            "error": {"code": -32601, "message": "Method not found"},
6392        }), flush=True)
6393        continue
6394    if request.get("method") == "initialize":
6395        result = {
6396            "protocolVersion": "2024-11-05",
6397            "capabilities": {"tools": {"listChanged": False}},
6398            "serverInfo": {"name": "config-order-fixture", "version": "1.0.0"},
6399        }
6400    elif request.get("method") == "tools/list":
6401        result = {"tools": []}
6402    else:
6403        result = {}
6404    print(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}), flush=True)
6405"#,
6406        )
6407        .unwrap();
6408        let python = ["python3", "python"]
6409            .into_iter()
6410            .find(|command| {
6411                std::process::Command::new(command)
6412                    .arg("--version")
6413                    .output()
6414                    .is_ok_and(|output| output.status.success())
6415            })
6416            .expect("a Python interpreter is required for the MCP ordering fixture");
6417
6418        let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6419        stop_config_watcher(&mut state);
6420        let state = Arc::new(state);
6421        let held_provider = state.provider.write().await;
6422        let (provider_ready_tx, provider_ready_rx) = std::sync::mpsc::channel();
6423        set_generic_before_provider_publish_test_hook(dir.path(), move || {
6424            provider_ready_tx.send(()).unwrap();
6425        });
6426
6427        let first = {
6428            let state = state.clone();
6429            tokio::spawn(async move {
6430                state
6431                    .update_config(
6432                        |config| {
6433                            config.provider = "copilot".to_string();
6434                            Ok(())
6435                        },
6436                        ConfigUpdateEffects {
6437                            reload_provider: true,
6438                            reconcile_mcp: true,
6439                        },
6440                    )
6441                    .await
6442            })
6443        };
6444        tokio::task::spawn_blocking(move || provider_ready_rx.recv().unwrap())
6445            .await
6446            .unwrap();
6447        assert!(
6448            state.config_io_lock.try_lock().is_err(),
6449            "the first writer must retain config_io_lock until its runtime effects finish"
6450        );
6451        assert!(
6452            !first.is_finished(),
6453            "the first writer must still be waiting to publish its provider"
6454        );
6455
6456        let later_mcp = McpConfig {
6457            version: 1,
6458            servers: vec![McpServerConfig {
6459                id: "later-winner".to_string(),
6460                name: None,
6461                enabled: true,
6462                transport: TransportConfig::Stdio(StdioConfig {
6463                    command: python.to_string(),
6464                    args: vec![script.to_string_lossy().into_owned()],
6465                    cwd: None,
6466                    env: std::collections::HashMap::new(),
6467                    env_encrypted: std::collections::HashMap::new(),
6468                    env_credential_refs: std::collections::HashMap::new(),
6469                    startup_timeout_ms: 2_000,
6470                }),
6471                request_timeout_ms: 2_000,
6472                healthcheck_interval_ms: 10_000,
6473                reconnect: ReconnectConfig {
6474                    enabled: false,
6475                    ..Default::default()
6476                },
6477                allowed_tools: vec![],
6478                denied_tools: vec![],
6479            }],
6480        };
6481        let second = {
6482            let state = state.clone();
6483            tokio::spawn(async move {
6484                state
6485                    .update_config(
6486                        move |config| {
6487                            config.mcp = later_mcp;
6488                            Ok(())
6489                        },
6490                        ConfigUpdateEffects {
6491                            reload_provider: false,
6492                            reconcile_mcp: true,
6493                        },
6494                    )
6495                    .await
6496            })
6497        };
6498
6499        drop(held_provider);
6500        first.await.unwrap().unwrap();
6501        let published = second.await.unwrap().unwrap();
6502        assert_eq!(published.mcp.servers[0].id, "later-winner");
6503        assert_eq!(state.config.read().await.mcp.servers[0].id, "later-winner");
6504        assert_eq!(
6505            state.mcp_manager.list_servers(),
6506            vec!["later-winner".to_string()],
6507            "the later durable config generation must remain the final runtime generation"
6508        );
6509        state.mcp_manager.shutdown_all().await;
6510    }
6511
6512    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6513    async fn generic_update_cancellation_after_commit_finishes_publication() {
6514        let dir = tempfile::tempdir().unwrap();
6515        let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6516        stop_config_watcher(&mut state);
6517        let state = Arc::new(state);
6518        let mut feed = state.account_sink.subscribe();
6519        let (reached_tx, reached_rx) = std::sync::mpsc::sync_channel(0);
6520        let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
6521        set_generic_before_event_test_hook(dir.path(), move || {
6522            reached_tx.send(()).unwrap();
6523            release_rx.recv().unwrap();
6524        });
6525
6526        let operation = {
6527            let state = state.clone();
6528            tokio::spawn(async move {
6529                state
6530                    .update_config(
6531                        |config| {
6532                            config.server.port = 22_240;
6533                            Ok(())
6534                        },
6535                        ConfigUpdateEffects::default(),
6536                    )
6537                    .await
6538            })
6539        };
6540        tokio::task::spawn_blocking(move || reached_rx.recv().unwrap())
6541            .await
6542            .unwrap();
6543        assert_eq!(
6544            state
6545                .config_facade
6546                .as_ref()
6547                .unwrap()
6548                .registry()
6549                .core
6550                .snapshot()
6551                .revision,
6552            1,
6553            "the abort boundary must follow durable commit and facade adoption"
6554        );
6555        operation.abort();
6556        assert!(operation.await.unwrap_err().is_cancelled());
6557        release_tx.send(()).unwrap();
6558        let converged = tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
6559            .await
6560            .expect("detached generic update must finish live publication");
6561        drop(converged);
6562
6563        assert_eq!(state.config.read().await.server.port, 22_240);
6564        assert_eq!(
6565            bamboo_config::ConfigFacade::open(dir.path())
6566                .unwrap()
6567                .effective_config()
6568                .server
6569                .port,
6570            22_240
6571        );
6572        assert!(matches!(
6573            next_config_event(&mut feed, "core").await,
6574            AgentEvent::ConfigChanged {
6575                section,
6576                revision: 1
6577            } if section == "core"
6578        ));
6579    }
6580
6581    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6582    async fn provider_update_cancellation_after_commit_finishes_publication() {
6583        let _key = bamboo_config::encryption::set_test_encryption_key([0x7d; 32]);
6584        let dir = tempfile::tempdir().unwrap();
6585        let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6586        stop_config_watcher(&mut state);
6587        let state = Arc::new(state);
6588        let mut feed = state.account_sink.subscribe();
6589        let (reached_tx, reached_rx) = std::sync::mpsc::sync_channel(0);
6590        let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
6591        set_generic_before_event_test_hook(dir.path(), move || {
6592            reached_tx.send(()).unwrap();
6593            release_rx.recv().unwrap();
6594        });
6595
6596        let operation = {
6597            let state = state.clone();
6598            tokio::spawn(async move {
6599                state
6600                    .update_config_with_provider_credentials(
6601                        |config| {
6602                            config.provider = "openai".to_string();
6603                            config.providers_mut().openai = Some(bamboo_config::OpenAIConfig {
6604                                api_key: "cancellation-secret".to_string(),
6605                                model: Some("cancellation-model".to_string()),
6606                                ..Default::default()
6607                            });
6608                            Ok(())
6609                        },
6610                        BTreeSet::from(["openai".to_string()]),
6611                        BTreeSet::new(),
6612                        ConfigUpdateEffects::default(),
6613                    )
6614                    .await
6615            })
6616        };
6617        tokio::task::spawn_blocking(move || reached_rx.recv().unwrap())
6618            .await
6619            .unwrap();
6620        assert_eq!(
6621            state
6622                .config_facade
6623                .as_ref()
6624                .unwrap()
6625                .registry()
6626                .providers
6627                .snapshot()
6628                .revision,
6629            1,
6630            "the abort boundary must follow provider durable/facade adoption"
6631        );
6632        operation.abort();
6633        assert!(operation.await.unwrap_err().is_cancelled());
6634        release_tx.send(()).unwrap();
6635        let converged = tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
6636            .await
6637            .expect("detached provider update must finish live publication");
6638        drop(converged);
6639
6640        assert_eq!(state.config.read().await.provider, "openai");
6641        let durable = bamboo_config::ConfigFacade::open(dir.path())
6642            .unwrap()
6643            .effective_config();
6644        assert_eq!(durable.provider, "openai");
6645        assert_eq!(
6646            durable
6647                .providers()
6648                .openai
6649                .as_ref()
6650                .unwrap()
6651                .model
6652                .as_deref(),
6653            Some("cancellation-model")
6654        );
6655        assert!(matches!(
6656            next_config_event(&mut feed, "providers").await,
6657            AgentEvent::ConfigChanged {
6658                section,
6659                revision: 1
6660            } if section == "providers"
6661        ));
6662        for file in ["providers.json", "credentials.json"] {
6663            assert!(
6664                !std::fs::read_to_string(dir.path().join(file))
6665                    .unwrap()
6666                    .contains("cancellation-secret"),
6667                "{file} must remain secret-free"
6668            );
6669        }
6670    }
6671
6672    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6673    async fn replace_config_cancellation_after_commit_finishes_publication() {
6674        let dir = tempfile::tempdir().unwrap();
6675        let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6676        stop_config_watcher(&mut state);
6677        let state = Arc::new(state);
6678        let mut replacement = state.config.read().await.clone();
6679        replacement.server.port = 22_241;
6680        let mut feed = state.account_sink.subscribe();
6681        let (reached_tx, reached_rx) = std::sync::mpsc::sync_channel(0);
6682        let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
6683        set_generic_before_event_test_hook(dir.path(), move || {
6684            reached_tx.send(()).unwrap();
6685            release_rx.recv().unwrap();
6686        });
6687
6688        let operation = {
6689            let state = state.clone();
6690            tokio::spawn(async move {
6691                state
6692                    .replace_config(replacement, ConfigUpdateEffects::default())
6693                    .await
6694            })
6695        };
6696        tokio::task::spawn_blocking(move || reached_rx.recv().unwrap())
6697            .await
6698            .unwrap();
6699        assert_eq!(
6700            state
6701                .config_facade
6702                .as_ref()
6703                .unwrap()
6704                .registry()
6705                .core
6706                .snapshot()
6707                .revision,
6708            1,
6709            "the abort boundary must follow replacement durable/facade adoption"
6710        );
6711        operation.abort();
6712        assert!(operation.await.unwrap_err().is_cancelled());
6713        release_tx.send(()).unwrap();
6714        let converged = tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
6715            .await
6716            .expect("detached replacement must finish live publication");
6717        drop(converged);
6718
6719        assert_eq!(state.config.read().await.server.port, 22_241);
6720        assert_eq!(
6721            bamboo_config::ConfigFacade::open(dir.path())
6722                .unwrap()
6723                .effective_config()
6724                .server
6725                .port,
6726            22_241
6727        );
6728        assert!(matches!(
6729            next_config_event(&mut feed, "core").await,
6730            AgentEvent::ConfigChanged {
6731                section,
6732                revision: 1
6733            } if section == "core"
6734        ));
6735    }
6736
6737    #[tokio::test]
6738    async fn deployed_node_delete_and_cluster_reset_reject_before_commit_and_remain_stoppable() {
6739        let dir = tempfile::tempdir().unwrap();
6740        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6741        state
6742            .update_cluster_fabric_credentials(
6743                0,
6744                BTreeMap::from([(
6745                    "live-node".to_string(),
6746                    bamboo_config::ClusterNodeCredentialIntents::clear_all(),
6747                )]),
6748                |config| {
6749                    config.cluster_fabric.nodes.push(bamboo_config::Node {
6750                        id: "live-node".to_string(),
6751                        label: "live-node".to_string(),
6752                        placement: bamboo_config::NodePlacement::Local,
6753                        trust_level: bamboo_config::TrustLevel::Trusted,
6754                        deploy: bamboo_config::DeployProfile::default(),
6755                        state: Some(bamboo_config::NodeState {
6756                            status: bamboo_config::NodeStatus::Running,
6757                            worker_id: Some("live-worker".to_string()),
6758                            ..Default::default()
6759                        }),
6760                        enabled: true,
6761                    });
6762                    Ok(())
6763                },
6764            )
6765            .await
6766            .unwrap();
6767        insert_registry_worker(
6768            &state,
6769            bamboo_server_tools::registry_keys::node_key("live-node"),
6770            "live-worker",
6771        )
6772        .await;
6773        let transaction_marker = dir.path().join("config-credential-migration.json");
6774        let marker_before_guard = std::fs::read(&transaction_marker).ok();
6775        bamboo_config::set_cluster_exact_commit_test_fault(
6776            dir.path().to_path_buf(),
6777            bamboo_config::ClusterExactCommitTestFault::AfterManifestRecoveryFailure,
6778        );
6779
6780        let delete = state
6781            .delete_cluster_node_credentials(
6782                1,
6783                "live-node".to_string(),
6784                BTreeMap::from([(
6785                    "live-node".to_string(),
6786                    bamboo_config::ClusterNodeCredentialIntents::clear_all(),
6787                )]),
6788                |config| {
6789                    config
6790                        .cluster_fabric
6791                        .nodes
6792                        .retain(|node| node.id != "live-node");
6793                    Ok(())
6794                },
6795            )
6796            .await;
6797        assert!(matches!(delete, Err(AppError::BadRequest(_))));
6798        let reset = state
6799            .reset_credential_backed_section(SectionId::ClusterFabric, 1)
6800            .await;
6801        assert!(matches!(reset, Err(ConfigSectionMutationError::Invalid(_))));
6802        assert_eq!(
6803            std::fs::read(&transaction_marker).ok(),
6804            marker_before_guard,
6805            "registry guards must reject before opening a new durable transaction"
6806        );
6807        assert_eq!(
6808            state
6809                .config_facade
6810                .as_ref()
6811                .unwrap()
6812                .registry()
6813                .cluster_fabric
6814                .snapshot()
6815                .revision,
6816            1
6817        );
6818        assert!(state
6819            .config
6820            .read()
6821            .await
6822            .cluster_fabric
6823            .node("live-node")
6824            .is_some());
6825        assert!(state
6826            .fabric_deployer
6827            .registry()
6828            .lock()
6829            .await
6830            .contains_key(&bamboo_server_tools::registry_keys::node_key("live-node")));
6831
6832        bamboo_config::clear_cluster_exact_commit_test_fault(dir.path());
6833        let stopped = state
6834            .fabric_deployer
6835            .stop_at_revision("live-node", 1)
6836            .await
6837            .unwrap();
6838        assert_eq!(stopped.snapshot.section.revision, 2);
6839        assert!(!state
6840            .fabric_deployer
6841            .registry()
6842            .lock()
6843            .await
6844            .contains_key(&bamboo_server_tools::registry_keys::node_key("live-node")));
6845        let deleted = state
6846            .delete_cluster_node_credentials(
6847                2,
6848                "live-node".to_string(),
6849                BTreeMap::from([(
6850                    "live-node".to_string(),
6851                    bamboo_config::ClusterNodeCredentialIntents::clear_all(),
6852                )]),
6853                |config| {
6854                    config
6855                        .cluster_fabric
6856                        .nodes
6857                        .retain(|node| node.id != "live-node");
6858                    Ok(())
6859                },
6860            )
6861            .await
6862            .unwrap();
6863        assert_eq!(deleted.section.revision, 3);
6864        assert!(deleted.config.cluster_fabric.node("live-node").is_none());
6865    }
6866
6867    #[tokio::test]
6868    async fn unrelated_agent_registry_entry_does_not_block_cluster_reset() {
6869        let dir = tempfile::tempdir().unwrap();
6870        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6871        state
6872            .update_cluster_fabric_credentials(
6873                0,
6874                BTreeMap::from([(
6875                    "reset-node".to_string(),
6876                    bamboo_config::ClusterNodeCredentialIntents::clear_all(),
6877                )]),
6878                |config| {
6879                    config.cluster_fabric.nodes.push(bamboo_config::Node {
6880                        id: "reset-node".to_string(),
6881                        label: "reset-node".to_string(),
6882                        placement: bamboo_config::NodePlacement::Local,
6883                        trust_level: bamboo_config::TrustLevel::Trusted,
6884                        deploy: bamboo_config::DeployProfile::default(),
6885                        state: None,
6886                        enabled: true,
6887                    });
6888                    Ok(())
6889                },
6890            )
6891            .await
6892            .unwrap();
6893        let agent_key = bamboo_server_tools::registry_keys::agent_key("unrelated-agent");
6894        insert_registry_worker(&state, agent_key.clone(), "unrelated-agent").await;
6895
6896        state
6897            .reset_credential_backed_section(SectionId::ClusterFabric, 1)
6898            .await
6899            .unwrap();
6900        assert_eq!(
6901            state
6902                .config_facade
6903                .as_ref()
6904                .unwrap()
6905                .registry()
6906                .cluster_fabric
6907                .snapshot()
6908                .revision,
6909            2
6910        );
6911        assert!(state.config.read().await.cluster_fabric.nodes.is_empty());
6912        let unrelated = state
6913            .fabric_deployer
6914            .registry()
6915            .lock()
6916            .await
6917            .remove(&agent_key)
6918            .expect("agent registry entry must survive cluster reset");
6919        unrelated.handle.shutdown().await;
6920    }
6921
6922    #[tokio::test]
6923    async fn operator_cluster_crud_recovers_before_finish_and_converges_once() {
6924        let _key = bamboo_config::encryption::set_test_encryption_key([0x72; 32]);
6925        let dir = tempfile::tempdir().unwrap();
6926        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6927        let baseline_seq = state.account_sink.latest_seq();
6928        bamboo_config::set_cluster_exact_commit_test_fault(
6929            dir.path().to_path_buf(),
6930            bamboo_config::ClusterExactCommitTestFault::BeforeFinish,
6931        );
6932
6933        let committed = state
6934            .update_cluster_fabric_credentials(
6935                0,
6936                BTreeMap::from([(
6937                    "recovered-crud-node".to_string(),
6938                    bamboo_config::ClusterNodeCredentialIntents::clear_all(),
6939                )]),
6940                |config| {
6941                    config.cluster_fabric.nodes.push(bamboo_config::Node {
6942                        id: "recovered-crud-node".to_string(),
6943                        label: "recovered-crud-node".to_string(),
6944                        placement: bamboo_config::NodePlacement::Local,
6945                        trust_level: bamboo_config::TrustLevel::Trusted,
6946                        deploy: bamboo_config::DeployProfile::default(),
6947                        state: None,
6948                        enabled: true,
6949                    });
6950                    Ok(())
6951                },
6952            )
6953            .await
6954            .expect("operator CRUD must recover the committed transaction");
6955        assert_eq!(committed.section.revision, 1);
6956        assert_eq!(
6957            committed
6958                .config
6959                .cluster_fabric
6960                .node("recovered-crud-node")
6961                .unwrap()
6962                .label,
6963            "recovered-crud-node"
6964        );
6965        assert_eq!(
6966            state
6967                .config
6968                .read()
6969                .await
6970                .cluster_fabric
6971                .node("recovered-crud-node")
6972                .unwrap()
6973                .label,
6974            "recovered-crud-node"
6975        );
6976        assert_eq!(
6977            state
6978                .config_facade
6979                .as_ref()
6980                .unwrap()
6981                .registry()
6982                .cluster_fabric
6983                .snapshot()
6984                .revision,
6985            1
6986        );
6987        let reopened = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
6988        assert_eq!(reopened.registry().cluster_fabric.snapshot().revision, 1);
6989        assert_eq!(
6990            reopened
6991                .effective_config()
6992                .cluster_fabric
6993                .node("recovered-crud-node")
6994                .unwrap()
6995                .label,
6996            "recovered-crud-node"
6997        );
6998        bamboo_config::ensure_provider_mcp_migration_ready(dir.path()).unwrap();
6999
7000        tokio::time::sleep(Duration::from_millis(100)).await;
7001        let events = bamboo_engine::events::journal::read_since(
7002            state.account_sink.events_dir(),
7003            baseline_seq,
7004        )
7005        .unwrap();
7006        let cluster_events = events
7007            .iter()
7008            .filter(|event| {
7009                matches!(
7010                    &event.event,
7011                    AgentEvent::ConfigChanged { section, revision }
7012                        if section == "cluster-fabric" && *revision == 1
7013                )
7014            })
7015            .count();
7016        assert_eq!(cluster_events, 1);
7017        assert!(!events.iter().any(|event| {
7018            matches!(
7019                &event.event,
7020                AgentEvent::ConfigChanged { section, .. } if section == "credentials"
7021            )
7022        }));
7023    }
7024
7025    #[tokio::test]
7026    async fn cluster_replace_and_keep_noop_retain_the_exact_hydrated_runtime() {
7027        let _key = bamboo_config::encryption::set_test_encryption_key([0x73; 32]);
7028        let dir = tempfile::tempdir().unwrap();
7029        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
7030        let baseline_seq = state.account_sink.latest_seq();
7031        let password_ref = bamboo_config::cluster_password_credential_ref("secret-node").unwrap();
7032        let password_from = |config: &Config| match &config
7033            .cluster_fabric
7034            .node("secret-node")
7035            .expect("secret node exists")
7036            .placement
7037        {
7038            bamboo_config::NodePlacement::Ssh(target) => match &target.auth {
7039                bamboo_config::SshAuth::Password { password, .. } => password.clone(),
7040                _ => panic!("expected password authentication"),
7041            },
7042            _ => panic!("expected SSH placement"),
7043        };
7044
7045        let replaced = state
7046            .update_cluster_fabric_credentials(
7047                0,
7048                BTreeMap::from([(
7049                    "secret-node".to_string(),
7050                    bamboo_config::ClusterNodeCredentialIntents {
7051                        password: bamboo_config::ClusterCredentialAction::Replace(
7052                            "exact-password".to_string(),
7053                        ),
7054                        private_key: bamboo_config::ClusterCredentialAction::Clear,
7055                        passphrase: bamboo_config::ClusterCredentialAction::Clear,
7056                    },
7057                )]),
7058                |config| {
7059                    config.cluster_fabric.nodes.push(bamboo_config::Node {
7060                        id: "secret-node".to_string(),
7061                        label: "secret-node".to_string(),
7062                        placement: bamboo_config::NodePlacement::Ssh(bamboo_config::SshTarget {
7063                            host: "secret.example.test".to_string(),
7064                            port: 22,
7065                            username: "operator".to_string(),
7066                            auth: bamboo_config::SshAuth::Password {
7067                                password: String::new(),
7068                                password_encrypted: None,
7069                            },
7070                            host_key_fingerprint: None,
7071                        }),
7072                        trust_level: bamboo_config::TrustLevel::Trusted,
7073                        deploy: bamboo_config::DeployProfile::default(),
7074                        state: None,
7075                        enabled: true,
7076                    });
7077                    Ok(())
7078                },
7079            )
7080            .await
7081            .unwrap();
7082        assert_eq!(replaced.section.revision, 1);
7083        assert_eq!(password_from(&replaced.config), "exact-password");
7084        assert_eq!(
7085            password_from(&*state.config.read().await),
7086            "exact-password",
7087            "live runtime must install the under-lock hydrated candidate"
7088        );
7089        assert_eq!(replaced.credential_health.revision, 1);
7090        assert_eq!(replaced.credential_statuses.len(), 1);
7091        assert_eq!(replaced.credential_statuses[0].credential_ref, password_ref);
7092        assert!(replaced.credential_statuses[0].configured);
7093
7094        tokio::time::sleep(Duration::from_millis(500)).await;
7095        let replace_events = bamboo_engine::events::journal::read_since(
7096            state.account_sink.events_dir(),
7097            baseline_seq,
7098        )
7099        .unwrap();
7100        let cluster_revisions = replace_events
7101            .iter()
7102            .filter_map(|event| match &event.event {
7103                AgentEvent::ConfigChanged { section, revision } if section == "cluster-fabric" => {
7104                    Some(*revision)
7105                }
7106                _ => None,
7107            })
7108            .collect::<Vec<_>>();
7109        assert_eq!(cluster_revisions, vec![1]);
7110        assert!(!replace_events.iter().any(|event| {
7111            matches!(
7112                &event.event,
7113                AgentEvent::ConfigChanged { section, .. }
7114                    | AgentEvent::ConfigInvalid { section, .. }
7115                    | AgentEvent::ConfigRecovered { section, .. }
7116                    if section == "credentials"
7117            )
7118        }));
7119
7120        let noop_baseline_seq = state.account_sink.latest_seq();
7121        let kept = state
7122            .update_cluster_fabric_credentials(
7123                1,
7124                BTreeMap::from([(
7125                    "secret-node".to_string(),
7126                    bamboo_config::ClusterNodeCredentialIntents {
7127                        password: bamboo_config::ClusterCredentialAction::Keep,
7128                        private_key: bamboo_config::ClusterCredentialAction::Clear,
7129                        passphrase: bamboo_config::ClusterCredentialAction::Clear,
7130                    },
7131                )]),
7132                |config| {
7133                    let node = config
7134                        .cluster_fabric
7135                        .node_mut("secret-node")
7136                        .expect("secret node exists");
7137                    let bamboo_config::NodePlacement::Ssh(target) = &mut node.placement else {
7138                        panic!("expected SSH placement")
7139                    };
7140                    let bamboo_config::SshAuth::Password {
7141                        password,
7142                        password_encrypted,
7143                    } = &mut target.auth
7144                    else {
7145                        panic!("expected password authentication")
7146                    };
7147                    password.clear();
7148                    *password_encrypted = None;
7149                    Ok(())
7150                },
7151            )
7152            .await
7153            .unwrap();
7154        assert_eq!(kept.section.revision, 1);
7155        assert_eq!(kept.credential_health.revision, 1);
7156        assert_eq!(password_from(&kept.config), "exact-password");
7157        assert_eq!(
7158            password_from(&*state.config.read().await),
7159            "exact-password",
7160            "semantic no-op must retain the exact credential snapshot"
7161        );
7162
7163        tokio::time::sleep(Duration::from_millis(500)).await;
7164        let noop_events = bamboo_engine::events::journal::read_since(
7165            state.account_sink.events_dir(),
7166            noop_baseline_seq,
7167        )
7168        .unwrap();
7169        assert!(!noop_events.iter().any(|event| {
7170            matches!(
7171                &event.event,
7172                AgentEvent::ConfigChanged { section, .. }
7173                    | AgentEvent::ConfigInvalid { section, .. }
7174                    | AgentEvent::ConfigRecovered { section, .. }
7175                    if section == "cluster-fabric" || section == "credentials"
7176            )
7177        }));
7178    }
7179
7180    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
7181    async fn later_external_credential_winner_remains_observable_after_exact_cluster_commit() {
7182        let _key = bamboo_config::encryption::set_test_encryption_key([0x74; 32]);
7183        let dir = tempfile::tempdir().unwrap();
7184        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
7185        let baseline_seq = state.account_sink.latest_seq();
7186        let password_ref =
7187            bamboo_config::cluster_password_credential_ref("credential-race-node").unwrap();
7188        let external_ref = password_ref.clone();
7189        let (external_done_tx, external_done_rx) = std::sync::mpsc::sync_channel(1);
7190        set_cluster_after_commit_before_adoption_test_hook(dir.path(), 0, move |data_dir| {
7191            let data_dir = data_dir.to_path_buf();
7192            let (started_tx, started_rx) = std::sync::mpsc::sync_channel(0);
7193            std::thread::spawn(move || {
7194                started_tx.send(()).unwrap();
7195                let result = bamboo_config::CredentialStore::open(&data_dir).replace(
7196                    external_ref,
7197                    "later-external-password",
7198                    bamboo_config::CredentialSource::User,
7199                    1,
7200                );
7201                external_done_tx.send(result).unwrap();
7202            });
7203            started_rx
7204                .recv_timeout(Duration::from_secs(5))
7205                .expect("external credential writer must launch under the commit lock");
7206        });
7207
7208        let committed = state
7209            .update_cluster_fabric_credentials(
7210                0,
7211                BTreeMap::from([(
7212                    "credential-race-node".to_string(),
7213                    bamboo_config::ClusterNodeCredentialIntents {
7214                        password: bamboo_config::ClusterCredentialAction::Replace(
7215                            "exact-commit-password".to_string(),
7216                        ),
7217                        private_key: bamboo_config::ClusterCredentialAction::Clear,
7218                        passphrase: bamboo_config::ClusterCredentialAction::Clear,
7219                    },
7220                )]),
7221                |config| {
7222                    config.cluster_fabric.nodes.push(bamboo_config::Node {
7223                        id: "credential-race-node".to_string(),
7224                        label: "credential-race-node".to_string(),
7225                        placement: bamboo_config::NodePlacement::Ssh(bamboo_config::SshTarget {
7226                            host: "race.example.test".to_string(),
7227                            port: 22,
7228                            username: "operator".to_string(),
7229                            auth: bamboo_config::SshAuth::Password {
7230                                password: String::new(),
7231                                password_encrypted: None,
7232                            },
7233                            host_key_fingerprint: None,
7234                        }),
7235                        trust_level: bamboo_config::TrustLevel::Trusted,
7236                        deploy: bamboo_config::DeployProfile::default(),
7237                        state: None,
7238                        enabled: true,
7239                    });
7240                    Ok(())
7241                },
7242            )
7243            .await
7244            .unwrap();
7245        let committed_password = match &committed
7246            .config
7247            .cluster_fabric
7248            .node("credential-race-node")
7249            .unwrap()
7250            .placement
7251        {
7252            bamboo_config::NodePlacement::Ssh(target) => match &target.auth {
7253                bamboo_config::SshAuth::Password { password, .. } => password,
7254                _ => panic!("expected password authentication"),
7255            },
7256            _ => panic!("expected SSH placement"),
7257        };
7258        assert_eq!(committed.section.revision, 1);
7259        assert_eq!(committed.credential_health.revision, 1);
7260        assert_eq!(committed_password, "exact-commit-password");
7261
7262        let external_revision = tokio::task::spawn_blocking(move || {
7263            external_done_rx
7264                .recv_timeout(Duration::from_secs(10))
7265                .expect("external credential writer must complete")
7266                .unwrap()
7267                .0
7268        })
7269        .await
7270        .unwrap();
7271        assert_eq!(external_revision, 2);
7272
7273        tokio::time::timeout(Duration::from_secs(5), async {
7274            loop {
7275                let facade_revision = state
7276                    .config_facade
7277                    .as_ref()
7278                    .unwrap()
7279                    .registry()
7280                    .credentials
7281                    .snapshot()
7282                    .revision;
7283                let events = bamboo_engine::events::journal::read_since(
7284                    state.account_sink.events_dir(),
7285                    baseline_seq,
7286                )
7287                .unwrap();
7288                let saw_external_event = events.iter().any(|event| {
7289                    matches!(
7290                        &event.event,
7291                        AgentEvent::ConfigChanged { section, revision }
7292                            if section == "credentials" && *revision == 2
7293                    )
7294                });
7295                if facade_revision == 2 && saw_external_event {
7296                    break;
7297                }
7298                tokio::time::sleep(Duration::from_millis(20)).await;
7299            }
7300        })
7301        .await
7302        .expect("watcher must expose the later credential revision");
7303        tokio::time::sleep(Duration::from_millis(250)).await;
7304
7305        let runtime_password = match &state
7306            .config
7307            .read()
7308            .await
7309            .cluster_fabric
7310            .node("credential-race-node")
7311            .unwrap()
7312            .placement
7313        {
7314            bamboo_config::NodePlacement::Ssh(target) => match &target.auth {
7315                bamboo_config::SshAuth::Password { password, .. } => password.clone(),
7316                _ => panic!("expected password authentication"),
7317            },
7318            _ => panic!("expected SSH placement"),
7319        };
7320        assert_eq!(
7321            runtime_password, "exact-commit-password",
7322            "a status-only credential event must not rewrite the exact cluster runtime"
7323        );
7324        let credential_dir = dir.path().to_path_buf();
7325        let durable_password = tokio::task::spawn_blocking(move || {
7326            bamboo_config::CredentialStore::open(credential_dir)
7327                .resolve(&password_ref)
7328                .unwrap()
7329                .unwrap()
7330                .expose()
7331                .to_string()
7332        })
7333        .await
7334        .unwrap();
7335        assert_eq!(durable_password, "later-external-password");
7336
7337        let events = bamboo_engine::events::journal::read_since(
7338            state.account_sink.events_dir(),
7339            baseline_seq,
7340        )
7341        .unwrap();
7342        let relevant = events
7343            .iter()
7344            .filter_map(|event| match &event.event {
7345                AgentEvent::ConfigChanged { section, revision }
7346                    if section == "cluster-fabric" || section == "credentials" =>
7347                {
7348                    Some((section.as_str(), *revision))
7349                }
7350                _ => None,
7351            })
7352            .collect::<Vec<_>>();
7353        assert_eq!(
7354            relevant,
7355            vec![("cluster-fabric", 1), ("credentials", 2)],
7356            "the exact cluster event must precede the genuine later credential winner"
7357        );
7358    }
7359
7360    #[tokio::test]
7361    async fn changed_cluster_commit_publishes_secret_free_runtime_before_materialization_error() {
7362        let _key = bamboo_config::encryption::set_test_encryption_key([0x75; 32]);
7363        let dir = tempfile::tempdir().unwrap();
7364        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
7365        let password_ref =
7366            bamboo_config::cluster_password_credential_ref("corrupt-secret-node").unwrap();
7367        state
7368            .update_cluster_fabric_credentials(
7369                0,
7370                BTreeMap::from([(
7371                    "corrupt-secret-node".to_string(),
7372                    bamboo_config::ClusterNodeCredentialIntents {
7373                        password: bamboo_config::ClusterCredentialAction::Replace(
7374                            "initial-password".to_string(),
7375                        ),
7376                        private_key: bamboo_config::ClusterCredentialAction::Clear,
7377                        passphrase: bamboo_config::ClusterCredentialAction::Clear,
7378                    },
7379                )]),
7380                |config| {
7381                    config.cluster_fabric.nodes.push(bamboo_config::Node {
7382                        id: "corrupt-secret-node".to_string(),
7383                        label: "before-corruption".to_string(),
7384                        placement: bamboo_config::NodePlacement::Ssh(bamboo_config::SshTarget {
7385                            host: "corrupt.example.test".to_string(),
7386                            port: 22,
7387                            username: "operator".to_string(),
7388                            auth: bamboo_config::SshAuth::Password {
7389                                password: String::new(),
7390                                password_encrypted: None,
7391                            },
7392                            host_key_fingerprint: None,
7393                        }),
7394                        trust_level: bamboo_config::TrustLevel::Trusted,
7395                        deploy: bamboo_config::DeployProfile::default(),
7396                        state: None,
7397                        enabled: true,
7398                    });
7399                    Ok(())
7400                },
7401            )
7402            .await
7403            .unwrap();
7404
7405        let credentials_path = dir.path().join("credentials.json");
7406        let mut document: Value =
7407            serde_json::from_slice(&std::fs::read(&credentials_path).unwrap()).unwrap();
7408        document["data"]["entries"][password_ref.as_str()]["ciphertext"] =
7409            Value::String("corrupt-ciphertext".to_string());
7410        std::fs::write(
7411            &credentials_path,
7412            serde_json::to_vec_pretty(&document).unwrap(),
7413        )
7414        .unwrap();
7415        tokio::time::sleep(Duration::from_millis(300)).await;
7416
7417        let noop_baseline_seq = state.account_sink.latest_seq();
7418        let noop = state
7419            .update_cluster_fabric_credentials(1, BTreeMap::new(), |_| Ok(()))
7420            .await;
7421        match noop {
7422            Err(AppError::InternalError(_)) => {}
7423            Err(error) => panic!("no-op materialization error was misclassified: {error}"),
7424            Ok(_) => panic!("corrupt credential unexpectedly materialized"),
7425        }
7426        let runtime = state.config.read().await;
7427        let node = runtime.cluster_fabric.node("corrupt-secret-node").unwrap();
7428        let bamboo_config::NodePlacement::Ssh(target) = &node.placement else {
7429            panic!("expected SSH placement")
7430        };
7431        let bamboo_config::SshAuth::Password { password, .. } = &target.auth else {
7432            panic!("expected password authentication")
7433        };
7434        assert_eq!(
7435            password, "initial-password",
7436            "a true no-op materialization failure must preserve the old runtime"
7437        );
7438        drop(runtime);
7439        assert_eq!(
7440            state
7441                .config_facade
7442                .as_ref()
7443                .unwrap()
7444                .registry()
7445                .cluster_fabric
7446                .snapshot()
7447                .revision,
7448            1
7449        );
7450        let noop_events = bamboo_engine::events::journal::read_since(
7451            state.account_sink.events_dir(),
7452            noop_baseline_seq,
7453        )
7454        .unwrap();
7455        assert!(!noop_events.iter().any(|event| {
7456            matches!(
7457                &event.event,
7458                AgentEvent::ConfigChanged { section, .. } if section == "cluster-fabric"
7459            )
7460        }));
7461
7462        let baseline_seq = state.account_sink.latest_seq();
7463        let result = state
7464            .update_cluster_fabric_credentials(1, BTreeMap::new(), |config| {
7465                config
7466                    .cluster_fabric
7467                    .node_mut("corrupt-secret-node")
7468                    .unwrap()
7469                    .label = "committed-metadata".to_string();
7470                Ok(())
7471            })
7472            .await;
7473        match result {
7474            Err(AppError::InternalError(_)) => {}
7475            Err(error) => panic!("post-commit materialization error was misclassified: {error}"),
7476            Ok(_) => panic!("corrupt credential unexpectedly materialized"),
7477        }
7478
7479        let runtime = state.config.read().await;
7480        let node = runtime.cluster_fabric.node("corrupt-secret-node").unwrap();
7481        assert_eq!(node.label, "committed-metadata");
7482        let bamboo_config::NodePlacement::Ssh(target) = &node.placement else {
7483            panic!("expected SSH placement")
7484        };
7485        let bamboo_config::SshAuth::Password {
7486            password,
7487            password_encrypted,
7488        } = &target.auth
7489        else {
7490            panic!("expected password authentication")
7491        };
7492        assert!(password.is_empty());
7493        assert!(password_encrypted.is_none());
7494        drop(runtime);
7495
7496        let section = state
7497            .config_facade
7498            .as_ref()
7499            .unwrap()
7500            .registry()
7501            .cluster_fabric
7502            .snapshot();
7503        assert_eq!(section.revision, 2);
7504        assert_eq!(
7505            section.data.0.node("corrupt-secret-node").unwrap().label,
7506            "committed-metadata"
7507        );
7508        let events = bamboo_engine::events::journal::read_since(
7509            state.account_sink.events_dir(),
7510            baseline_seq,
7511        )
7512        .unwrap();
7513        let cluster_revisions = events
7514            .iter()
7515            .filter_map(|event| match &event.event {
7516                AgentEvent::ConfigChanged { section, revision } if section == "cluster-fabric" => {
7517                    Some(*revision)
7518                }
7519                _ => None,
7520            })
7521            .collect::<Vec<_>>();
7522        assert_eq!(cluster_revisions, vec![2]);
7523    }
7524
7525    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
7526    async fn cluster_commit_adopts_exact_revision_before_later_external_winner() {
7527        let _key = bamboo_config::encryption::set_test_encryption_key([0x72; 32]);
7528        let dir = tempfile::tempdir().unwrap();
7529        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
7530        let baseline_seq = state.account_sink.latest_seq();
7531        let (external_done_tx, external_done_rx) = std::sync::mpsc::sync_channel(1);
7532        set_cluster_after_commit_before_adoption_test_hook(dir.path(), 0, move |data_dir| {
7533            let data_dir = data_dir.to_path_buf();
7534            let (started_tx, started_rx) = std::sync::mpsc::sync_channel(0);
7535            std::thread::spawn(move || {
7536                started_tx.send(()).unwrap();
7537                let external = bamboo_config::ConfigFacade::open(&data_dir).unwrap();
7538                let mut winner = external.effective_config();
7539                winner.cluster_fabric.node_mut("race-node").unwrap().label =
7540                    "external-winner".to_string();
7541                let result =
7542                    bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
7543                        &data_dir,
7544                        &mut winner,
7545                        &BTreeMap::new(),
7546                        1,
7547                    );
7548                external_done_tx.send(result).unwrap();
7549            });
7550            started_rx
7551                .recv_timeout(Duration::from_secs(5))
7552                .expect("external writer must launch after the durable commit");
7553        });
7554
7555        let committed = state
7556            .update_cluster_fabric_credentials(
7557                0,
7558                BTreeMap::from([(
7559                    "race-node".to_string(),
7560                    bamboo_config::ClusterNodeCredentialIntents::clear_all(),
7561                )]),
7562                |config| {
7563                    config.cluster_fabric.nodes.push(bamboo_config::Node {
7564                        id: "race-node".to_string(),
7565                        label: "api-commit".to_string(),
7566                        placement: bamboo_config::NodePlacement::Local,
7567                        trust_level: bamboo_config::TrustLevel::Trusted,
7568                        deploy: bamboo_config::DeployProfile::default(),
7569                        state: None,
7570                        enabled: true,
7571                    });
7572                    Ok(())
7573                },
7574            )
7575            .await
7576            .unwrap();
7577        assert_eq!(committed.section.revision, 1);
7578        assert_eq!(
7579            committed
7580                .config
7581                .cluster_fabric
7582                .node("race-node")
7583                .unwrap()
7584                .label,
7585            "api-commit",
7586            "the response must remain bound to its exact committed candidate"
7587        );
7588        assert_eq!(
7589            tokio::task::spawn_blocking(move || {
7590                external_done_rx
7591                    .recv_timeout(Duration::from_secs(10))
7592                    .expect("later external winner must complete")
7593                    .unwrap()
7594            })
7595            .await
7596            .unwrap(),
7597            2
7598        );
7599
7600        tokio::time::timeout(Duration::from_secs(5), async {
7601            loop {
7602                let facade_revision = state
7603                    .config_facade
7604                    .as_ref()
7605                    .unwrap()
7606                    .registry()
7607                    .cluster_fabric
7608                    .snapshot()
7609                    .revision;
7610                let runtime_label = state
7611                    .config
7612                    .read()
7613                    .await
7614                    .cluster_fabric
7615                    .node("race-node")
7616                    .map(|node| node.label.clone());
7617                if facade_revision == 2 && runtime_label.as_deref() == Some("external-winner") {
7618                    break;
7619                }
7620                tokio::time::sleep(Duration::from_millis(20)).await;
7621            }
7622        })
7623        .await
7624        .expect("watcher must apply the later external revision");
7625
7626        let events = bamboo_engine::events::journal::read_since(
7627            state.account_sink.events_dir(),
7628            baseline_seq,
7629        )
7630        .unwrap();
7631        let revisions = events
7632            .iter()
7633            .filter_map(|event| match &event.event {
7634                AgentEvent::ConfigChanged { section, revision } if section == "cluster-fabric" => {
7635                    Some(*revision)
7636                }
7637                _ => None,
7638            })
7639            .collect::<Vec<_>>();
7640        assert_eq!(
7641            revisions,
7642            vec![1, 2],
7643            "the exact API event must precede the later watcher winner exactly once"
7644        );
7645        assert!(!events.iter().any(|event| {
7646            matches!(
7647                &event.event,
7648                AgentEvent::ConfigChanged { section, .. } if section == "credentials"
7649            )
7650        }));
7651    }
7652
7653    async fn wait_for_facade_health(
7654        state: &AppState,
7655        id: SectionId,
7656        status: SectionStatus,
7657        revision: u64,
7658    ) -> bamboo_config::SectionHealth {
7659        tokio::time::timeout(Duration::from_secs(4), async {
7660            loop {
7661                let health = state
7662                    .config_facade
7663                    .as_ref()
7664                    .expect("production state owns a facade")
7665                    .registry()
7666                    .health()
7667                    .unwrap()
7668                    .into_iter()
7669                    .find(|health| health.section == id)
7670                    .unwrap();
7671                if health.status == status && health.revision == revision {
7672                    break health;
7673                }
7674                tokio::time::sleep(Duration::from_millis(20)).await;
7675            }
7676        })
7677        .await
7678        .expect("facade health transition timed out")
7679    }
7680
7681    #[test]
7682    fn initial_provider_health_validates_primary_and_backup() {
7683        let dir = tempfile::tempdir().unwrap();
7684        let store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
7685        let missing = initial_provider_health(&store);
7686        assert_eq!(missing.status, SectionStatus::Missing);
7687        assert_eq!(missing.source_kind, SectionSourceKind::Default);
7688
7689        std::fs::write(dir.path().join("providers.json"), b"{broken").unwrap();
7690        let invalid = initial_provider_health(&store);
7691        assert_eq!(invalid.status, SectionStatus::Invalid);
7692        assert_eq!(invalid.source_kind, SectionSourceKind::File);
7693
7694        std::fs::write(dir.path().join("providers.json.bak"), b"{}").unwrap();
7695        let recovered = initial_provider_health(&store);
7696        assert_eq!(recovered.status, SectionStatus::Degraded);
7697        assert_eq!(recovered.source_kind, SectionSourceKind::Backup);
7698        assert!(recovered
7699            .last_error
7700            .as_deref()
7701            .unwrap()
7702            .contains("last-known-good backup"));
7703
7704        std::fs::write(dir.path().join("providers.json"), b"{}").unwrap();
7705        let healthy = initial_provider_health(&store);
7706        assert_eq!(healthy.status, SectionStatus::Healthy);
7707        assert_eq!(healthy.source_kind, SectionSourceKind::File);
7708    }
7709
7710    #[tokio::test]
7711    async fn unrecoverable_pending_manifest_never_publishes_partial_provider_state() {
7712        let _key = bamboo_config::encryption::set_test_encryption_key([0x6c; 32]);
7713        let dir = tempfile::tempdir().unwrap();
7714        install_unrecoverable_pending_provider_migration(dir.path());
7715
7716        let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
7717        assert_eq!(
7718            loaded.providers().openai.as_ref().unwrap().model.as_deref(),
7719            Some("root-lkg")
7720        );
7721        let store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
7722        let health = initial_provider_health(&store);
7723        assert_eq!(health.status, SectionStatus::Degraded);
7724        assert!(health
7725            .last_error
7726            .as_deref()
7727            .unwrap()
7728            .contains("migration is pending"));
7729        let error = match load_and_prepare_provider_candidate(&store, 0, loaded).await {
7730            Ok(_) => panic!("pending migration must reject provider candidate"),
7731            Err(error) => error,
7732        };
7733        assert!(error.message.contains("retaining last-known-good runtime"));
7734        assert!(!error.message.contains("partial-must-not-load"));
7735
7736        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
7737        assert_eq!(
7738            state
7739                .config
7740                .read()
7741                .await
7742                .providers()
7743                .openai
7744                .as_ref()
7745                .unwrap()
7746                .model
7747                .as_deref(),
7748            Some("root-lkg")
7749        );
7750        assert_eq!(
7751            state
7752                .config_live_health
7753                .read()
7754                .unwrap_or_else(|poisoned| poisoned.into_inner())
7755                .status,
7756            SectionStatus::Degraded
7757        );
7758    }
7759
7760    #[async_trait::async_trait]
7761    impl LLMProvider for WorkingProvider {
7762        async fn chat_stream(
7763            &self,
7764            _messages: &[Message],
7765            _tools: &[ToolSchema],
7766            _max_output_tokens: Option<u32>,
7767            _model: &str,
7768        ) -> Result<LLMStream, LLMError> {
7769            Err(LLMError::Api("working-provider-marker".to_string()))
7770        }
7771    }
7772
7773    #[tokio::test]
7774    async fn cancelled_provider_put_cannot_commit_before_publication_guards() {
7775        let _key = bamboo_config::encryption::set_test_encryption_key([0x53; 32]);
7776        let dir = tempfile::tempdir().unwrap();
7777        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
7778        let secret = "provider-cancel-secret";
7779        let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
7780        bamboo_config::CredentialStore::open(dir.path())
7781            .replace(
7782                reference.clone(),
7783                secret,
7784                bamboo_config::CredentialSource::User,
7785                0,
7786            )
7787            .unwrap();
7788        {
7789            let mut config = state.config.write().await;
7790            config.provider = "openai".to_string();
7791            *config.providers_mut() = ProviderConfigs {
7792                openai: Some(bamboo_config::OpenAIConfig {
7793                    api_key: secret.to_string(),
7794                    credential_ref: Some(reference),
7795                    ..Default::default()
7796                }),
7797                ..Default::default()
7798            };
7799        }
7800        let provider_lock = state.provider.clone();
7801        let held_provider = provider_lock.write().await;
7802        let providers_before = std::fs::read(dir.path().join("providers.json")).unwrap();
7803        let mut operation = Box::pin(state.put_provider_section(
7804            0,
7805            ProviderConfigs {
7806                openai: Some(bamboo_config::OpenAIConfig {
7807                    model: Some("candidate-model".to_string()),
7808                    ..Default::default()
7809                }),
7810                ..Default::default()
7811            },
7812        ));
7813
7814        assert!(
7815            tokio::time::timeout(Duration::from_millis(500), &mut operation)
7816                .await
7817                .is_err()
7818        );
7819        drop(operation);
7820        assert_eq!(
7821            std::fs::read(dir.path().join("providers.json")).unwrap(),
7822            providers_before,
7823            "cancellation while waiting for publication guards must precede durable commit"
7824        );
7825        drop(held_provider);
7826    }
7827
7828    #[test]
7829    fn cancelled_provider_settings_request_finishes_exact_commit_and_live_publication() {
7830        let _key = bamboo_config::encryption::set_test_encryption_key([0x71; 32]);
7831        let runtime = tokio::runtime::Builder::new_multi_thread()
7832            .worker_threads(2)
7833            .max_blocking_threads(1)
7834            .enable_all()
7835            .build()
7836            .unwrap();
7837        runtime.block_on(async {
7838            let dir = tempfile::tempdir().unwrap();
7839            let state = Arc::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
7840            wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
7841
7842            let (started_tx, started_rx) = tokio::sync::oneshot::channel();
7843            let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
7844            let blocker = tokio::task::spawn_blocking(move || {
7845                let _ = started_tx.send(());
7846                release_rx.recv().unwrap();
7847            });
7848            started_rx.await.unwrap();
7849
7850            let operation_state = state.clone();
7851            let operation = tokio::spawn(async move {
7852                operation_state
7853                    .put_provider_settings(0, |_current, candidate| {
7854                        candidate.provider = "openai".to_string();
7855                        candidate.providers_mut().openai = Some(bamboo_config::OpenAIConfig {
7856                            api_key: "provider-settings-cancel-secret".to_string(),
7857                            model: Some("provider-settings-cancel-model".to_string()),
7858                            ..Default::default()
7859                        });
7860                        Ok((BTreeSet::from(["openai".to_string()]), BTreeSet::new()))
7861                    })
7862                    .await
7863            });
7864
7865            tokio::time::timeout(Duration::from_secs(1), async {
7866                loop {
7867                    if state.config_io_lock.try_lock().is_err() {
7868                        break;
7869                    }
7870                    assert!(!operation.is_finished());
7871                    tokio::task::yield_now().await;
7872                }
7873            })
7874            .await
7875            .expect("provider settings mutation acquires the config IO lock");
7876            operation.abort();
7877            let _ = operation.await;
7878            release_tx.send(()).unwrap();
7879            blocker.await.unwrap();
7880
7881            tokio::time::timeout(Duration::from_secs(5), async {
7882                loop {
7883                    let committed = std::fs::read(dir.path().join("providers.json"))
7884                        .ok()
7885                        .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
7886                        .is_some_and(|value| {
7887                            value["revision"] == 1
7888                                && value["data"]["openai"]["model"]
7889                                    == "provider-settings-cancel-model"
7890                        });
7891                    if committed {
7892                        break;
7893                    }
7894                    tokio::task::yield_now().await;
7895                }
7896            })
7897            .await
7898            .expect("owned provider settings transaction completes after cancellation");
7899
7900            let converged =
7901                tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
7902                    .await
7903                    .expect("owned provider runtime publication completes after cancellation");
7904            drop(converged);
7905            let live = state.config.read().await;
7906            let openai = live.providers().openai.as_ref().unwrap();
7907            assert_eq!(
7908                openai.model.as_deref(),
7909                Some("provider-settings-cancel-model")
7910            );
7911            assert_eq!(openai.api_key, "provider-settings-cancel-secret");
7912            drop(live);
7913            let providers = std::fs::read_to_string(dir.path().join("providers.json")).unwrap();
7914            let credentials = std::fs::read_to_string(dir.path().join("credentials.json")).unwrap();
7915            assert!(!providers.contains("provider-settings-cancel-secret"));
7916            assert!(!credentials.contains("provider-settings-cancel-secret"));
7917        });
7918    }
7919
7920    #[test]
7921    fn cancelled_proxy_update_cannot_leave_durable_state_ahead_of_live_snapshot() {
7922        let runtime = tokio::runtime::Builder::new_multi_thread()
7923            .worker_threads(2)
7924            .max_blocking_threads(1)
7925            .enable_all()
7926            .build()
7927            .unwrap();
7928        runtime.block_on(async {
7929            let dir = tempfile::tempdir().unwrap();
7930            let state = Arc::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
7931            wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
7932
7933            // Occupy Tokio's only blocking worker. The pre-fix implementation
7934            // queued the durable transaction with `spawn_blocking`, so aborting
7935            // the request detached that queued commit from live publication.
7936            let (started_tx, started_rx) = tokio::sync::oneshot::channel();
7937            let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
7938            let blocker = tokio::task::spawn_blocking(move || {
7939                let _ = started_tx.send(());
7940                release_rx.recv().unwrap();
7941            });
7942            started_rx.await.unwrap();
7943
7944            let operation_state = state.clone();
7945            let operation = tokio::spawn(async move {
7946                operation_state
7947                    .update_proxy_auth_credential(
7948                        Some(bamboo_config::ProxyAuth {
7949                            username: "cancel-user".to_string(),
7950                            password: "cancel-secret".to_string(),
7951                        }),
7952                        0,
7953                        ConfigUpdateEffects {
7954                            reload_provider: true,
7955                            reconcile_mcp: true,
7956                        },
7957                    )
7958                    .await
7959            });
7960
7961            // Wait until the owned mutation has acquired config_io_lock and
7962            // queued its blocking transaction. Aborting the caller from this
7963            // exact point reproduced the old detached-commit/live-publication
7964            // split deterministically.
7965            tokio::time::timeout(Duration::from_secs(1), async {
7966                loop {
7967                    if state.config_io_lock.try_lock().is_err() {
7968                        break;
7969                    }
7970                    assert!(!operation.is_finished());
7971                    tokio::task::yield_now().await;
7972                }
7973            })
7974            .await
7975            .expect("proxy mutation acquires the config IO lock");
7976            operation.abort();
7977            let _ = operation.await;
7978            release_tx.send(()).unwrap();
7979            blocker.await.unwrap();
7980
7981            tokio::time::timeout(Duration::from_secs(5), async {
7982                loop {
7983                    let credentials_ready = std::fs::read(dir.path().join("credentials.json"))
7984                        .ok()
7985                        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
7986                        .and_then(|value| value.get("revision").and_then(|value| value.as_u64()))
7987                        == Some(1);
7988                    let config_ready = std::fs::read(dir.path().join("core.json"))
7989                        .ok()
7990                        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
7991                        .and_then(|value| {
7992                            value
7993                                .get("data")
7994                                .and_then(|value| value.get("proxy_auth_credential_ref"))
7995                                .and_then(|value| value.as_str())
7996                                .map(str::to_string)
7997                        })
7998                        .as_deref()
7999                        == Some("proxy.default.auth");
8000                    if credentials_ready && config_ready {
8001                        break;
8002                    }
8003                    tokio::task::yield_now().await;
8004                }
8005            })
8006            .await
8007            .expect("owned durable transaction completes after caller cancellation");
8008
8009            // The owner retains config_io_lock through best-effort provider/MCP
8010            // convergence. Acquiring it proves cancellation did not strand the
8011            // post-commit runtime task either.
8012            let converged =
8013                tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
8014                    .await
8015                    .expect("owned runtime convergence completes after cancellation");
8016            drop(converged);
8017
8018            let live = state.config.read().await;
8019            assert_eq!(
8020                live.proxy_auth_credential_ref
8021                    .as_ref()
8022                    .map(|reference| reference.as_str()),
8023                Some("proxy.default.auth")
8024            );
8025            let auth = live
8026                .proxy_auth
8027                .as_ref()
8028                .expect("durable proxy auth must be published despite cancellation");
8029            assert_eq!(auth.username, "cancel-user");
8030            assert_eq!(auth.password, "cancel-secret");
8031            drop(live);
8032
8033            let root = std::fs::read_to_string(dir.path().join("core.json")).unwrap();
8034            let credentials = std::fs::read_to_string(dir.path().join("credentials.json")).unwrap();
8035            assert!(!root.contains("cancel-secret"));
8036            assert!(!credentials.contains("cancel-secret"));
8037        });
8038    }
8039
8040    #[tokio::test]
8041    async fn missing_or_corrupt_referenced_credentials_reject_candidates_redacted() {
8042        let _key = bamboo_config::encryption::set_test_encryption_key([0x68; 32]);
8043        for corrupt_credentials in [false, true] {
8044            let dir = tempfile::tempdir().unwrap();
8045            let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
8046            let providers = ProviderConfigs {
8047                openai: Some(bamboo_config::OpenAIConfig {
8048                    credential_ref: Some(reference),
8049                    model: Some("candidate".to_string()),
8050                    ..Default::default()
8051                }),
8052                ..Default::default()
8053            };
8054            let provider_store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
8055            provider_store
8056                .commit(0, providers, validate_provider_config)
8057                .unwrap();
8058            if corrupt_credentials {
8059                std::fs::write(dir.path().join("credentials.json"), b"{corrupt-secret").unwrap();
8060            }
8061            let error =
8062                match load_and_prepare_provider_candidate(&provider_store, 0, Config::default())
8063                    .await
8064                {
8065                    Ok(_) => panic!("unavailable credential must reject provider candidate"),
8066                    Err(error) => error,
8067                };
8068            assert_eq!(error.message, "provider credential is unavailable");
8069            assert!(!error
8070                .message
8071                .contains(dir.path().to_string_lossy().as_ref()));
8072
8073            let mut mcp = disabled_mcp_config("credential-lkg");
8074            let TransportConfig::Stdio(stdio) = &mut mcp.servers[0].transport else {
8075                unreachable!()
8076            };
8077            stdio.env_credential_refs.insert(
8078                "TOKEN".to_string(),
8079                bamboo_config::credential_ref("mcp", "credential-lkg", "env_TOKEN")
8080                    .unwrap()
8081                    .as_str()
8082                    .to_string(),
8083            );
8084            let mcp_store = AtomicJsonStore::new(dir.path().join("mcp.json"), 1);
8085            mcp_store.commit(0, mcp, validate_mcp_config).unwrap();
8086            let error = match load_and_validate_mcp_candidate(
8087                &mcp_store,
8088                0,
8089                Config::default(),
8090                false,
8091            )
8092            .await
8093            {
8094                Ok(_) => panic!("unavailable credential must reject MCP candidate"),
8095                Err(error) => error,
8096            };
8097            assert_eq!(error.message, "MCP credential is unavailable");
8098            assert!(!error.message.contains("TOKEN"));
8099            assert!(!error
8100                .message
8101                .contains(dir.path().to_string_lossy().as_ref()));
8102        }
8103    }
8104
8105    #[tokio::test]
8106    async fn typed_provider_put_switches_refs_and_rejects_missing_ref_without_mutation() {
8107        let _key = bamboo_config::encryption::set_test_encryption_key([0x6d; 32]);
8108        let dir = tempfile::tempdir().unwrap();
8109        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8110        let ref_a = bamboo_config::credential_ref("provider", "openai-a", "api_key").unwrap();
8111        let ref_b = bamboo_config::credential_ref("provider", "openai-b", "api_key").unwrap();
8112        let missing = bamboo_config::credential_ref("provider", "missing", "api_key").unwrap();
8113        state
8114            .credential_store
8115            .replace(
8116                ref_a.clone(),
8117                "provider-secret-a",
8118                bamboo_config::CredentialSource::User,
8119                0,
8120            )
8121            .unwrap();
8122        state
8123            .credential_store
8124            .replace(
8125                ref_b.clone(),
8126                "provider-secret-b",
8127                bamboo_config::CredentialSource::User,
8128                1,
8129            )
8130            .unwrap();
8131        {
8132            let mut config = state.config.write().await;
8133            config.provider = "openai".to_string();
8134            *config.providers_mut() = ProviderConfigs {
8135                openai: Some(bamboo_config::OpenAIConfig {
8136                    api_key: "provider-secret-a".to_string(),
8137                    credential_ref: Some(ref_a),
8138                    ..Default::default()
8139                }),
8140                ..Default::default()
8141            };
8142        }
8143
8144        let revision = state
8145            .put_provider_section(
8146                0,
8147                ProviderConfigs {
8148                    openai: Some(bamboo_config::OpenAIConfig {
8149                        credential_ref: Some(ref_b.clone()),
8150                        model: Some("switched".to_string()),
8151                        ..Default::default()
8152                    }),
8153                    ..Default::default()
8154                },
8155            )
8156            .await
8157            .unwrap();
8158        assert_eq!(revision, 1);
8159        let runtime = state.config.read().await;
8160        let openai = runtime.providers().openai.as_ref().unwrap();
8161        assert_eq!(openai.credential_ref.as_ref(), Some(&ref_b));
8162        assert_eq!(openai.api_key, "provider-secret-b");
8163        drop(runtime);
8164        let disk_before = std::fs::read(dir.path().join("providers.json")).unwrap();
8165
8166        assert!(state
8167            .put_provider_section(
8168                1,
8169                ProviderConfigs {
8170                    openai: Some(bamboo_config::OpenAIConfig {
8171                        credential_ref: Some(missing),
8172                        model: Some("must-not-publish".to_string()),
8173                        ..Default::default()
8174                    }),
8175                    ..Default::default()
8176                },
8177            )
8178            .await
8179            .is_err());
8180        assert_eq!(
8181            std::fs::read(dir.path().join("providers.json")).unwrap(),
8182            disk_before
8183        );
8184        let runtime = state.config.read().await;
8185        let openai = runtime.providers().openai.as_ref().unwrap();
8186        assert_eq!(openai.credential_ref.as_ref(), Some(&ref_b));
8187        assert_eq!(openai.api_key, "provider-secret-b");
8188    }
8189
8190    #[tokio::test]
8191    async fn typed_mcp_put_switches_stdio_and_header_refs_atomically() {
8192        let _key = bamboo_config::encryption::set_test_encryption_key([0x6e; 32]);
8193        let dir = tempfile::tempdir().unwrap();
8194        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8195        let refs = [
8196            bamboo_config::credential_ref("mcp", "stdio-a", "env_TOKEN").unwrap(),
8197            bamboo_config::credential_ref("mcp", "header-a", "header_Authorization").unwrap(),
8198            bamboo_config::credential_ref("mcp", "stdio-b", "env_TOKEN").unwrap(),
8199            bamboo_config::credential_ref("mcp", "header-b", "header_Authorization").unwrap(),
8200        ];
8201        for (revision, (reference, value)) in refs
8202            .iter()
8203            .zip(["env-a", "header-a", "env-b", "header-b"])
8204            .enumerate()
8205        {
8206            state
8207                .credential_store
8208                .replace(
8209                    reference.clone(),
8210                    value,
8211                    bamboo_config::CredentialSource::User,
8212                    revision as u64,
8213                )
8214                .unwrap();
8215        }
8216        let make_config = |env_ref: &bamboo_config::CredentialRef,
8217                           header_ref: &bamboo_config::CredentialRef| {
8218            McpConfig {
8219                version: 1,
8220                servers: vec![
8221                    McpServerConfig {
8222                        id: "switch-stdio".to_string(),
8223                        name: None,
8224                        enabled: false,
8225                        transport: TransportConfig::Stdio(StdioConfig {
8226                            command: "unused-disabled-command".to_string(),
8227                            args: vec![],
8228                            cwd: None,
8229                            env: std::collections::HashMap::new(),
8230                            env_encrypted: std::collections::HashMap::new(),
8231                            env_credential_refs: std::collections::HashMap::from([(
8232                                "TOKEN".to_string(),
8233                                env_ref.as_str().to_string(),
8234                            )]),
8235                            startup_timeout_ms: 100,
8236                        }),
8237                        request_timeout_ms: 100,
8238                        healthcheck_interval_ms: 100,
8239                        reconnect: ReconnectConfig::default(),
8240                        allowed_tools: vec![],
8241                        denied_tools: vec![],
8242                    },
8243                    McpServerConfig {
8244                        id: "switch-header".to_string(),
8245                        name: None,
8246                        enabled: false,
8247                        transport: TransportConfig::Sse(bamboo_mcp::SseConfig {
8248                            url: "https://example.test/sse".to_string(),
8249                            headers: vec![bamboo_mcp::HeaderConfig {
8250                                name: "Authorization".to_string(),
8251                                value: String::new(),
8252                                value_encrypted: None,
8253                                credential_ref: Some(header_ref.as_str().to_string()),
8254                            }],
8255                            connect_timeout_ms: 100,
8256                        }),
8257                        request_timeout_ms: 100,
8258                        healthcheck_interval_ms: 100,
8259                        reconnect: ReconnectConfig::default(),
8260                        allowed_tools: vec![],
8261                        denied_tools: vec![],
8262                    },
8263                ],
8264            }
8265        };
8266        let mut current = make_config(&refs[0], &refs[1]);
8267        if let TransportConfig::Stdio(stdio) = &mut current.servers[0].transport {
8268            stdio.env.insert("TOKEN".to_string(), "env-a".to_string());
8269        }
8270        if let TransportConfig::Sse(sse) = &mut current.servers[1].transport {
8271            sse.headers[0].value = "header-a".to_string();
8272        }
8273        state.config.write().await.mcp = current;
8274
8275        assert_eq!(
8276            state
8277                .put_mcp_section(0, make_config(&refs[2], &refs[3]))
8278                .await
8279                .unwrap(),
8280            1
8281        );
8282        let runtime = state.config.read().await;
8283        let TransportConfig::Stdio(stdio) = &runtime.mcp.servers[0].transport else {
8284            panic!("stdio transport")
8285        };
8286        assert_eq!(stdio.env["TOKEN"], "env-b");
8287        let TransportConfig::Sse(sse) = &runtime.mcp.servers[1].transport else {
8288            panic!("sse transport")
8289        };
8290        assert_eq!(sse.headers[0].value, "header-b");
8291        drop(runtime);
8292        let disk_before = std::fs::read(dir.path().join("mcp.json")).unwrap();
8293        let missing_env = bamboo_config::credential_ref("mcp", "missing", "env_TOKEN").unwrap();
8294        let missing_header =
8295            bamboo_config::credential_ref("mcp", "missing", "header_Authorization").unwrap();
8296        assert!(state
8297            .put_mcp_section(1, make_config(&missing_env, &missing_header))
8298            .await
8299            .is_err());
8300        assert_eq!(
8301            std::fs::read(dir.path().join("mcp.json")).unwrap(),
8302            disk_before
8303        );
8304        let runtime = state.config.read().await;
8305        let TransportConfig::Stdio(stdio) = &runtime.mcp.servers[0].transport else {
8306            panic!("stdio transport")
8307        };
8308        assert_eq!(stdio.env["TOKEN"], "env-b");
8309    }
8310
8311    #[tokio::test]
8312    async fn failed_candidate_keeps_existing_provider_registry_and_runtime() {
8313        let dir = tempfile::tempdir().unwrap();
8314        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8315        let working: Arc<dyn LLMProvider> = Arc::new(WorkingProvider);
8316        state
8317            .provider_registry
8318            .insert("working".to_string(), working.clone());
8319        state.provider_registry.set_default("working".to_string());
8320        *state.provider.write().await = working.clone();
8321        state.config.write().await.provider = "openai".to_string();
8322
8323        assert!(state.reload_provider().await.is_err());
8324        assert_eq!(state.provider_registry.default_provider_name(), "working");
8325        assert!(Arc::ptr_eq(
8326            &state.provider_registry.get_default().unwrap(),
8327            &working
8328        ));
8329        let live = state.provider.read().await;
8330        assert!(Arc::ptr_eq(&*live, &working));
8331    }
8332
8333    #[tokio::test]
8334    async fn provider_watcher_retains_lkg_on_invalid_and_recovers_after_repair() {
8335        let _key = bamboo_config::encryption::set_test_encryption_key([0x43; 32]);
8336        let dir = tempfile::tempdir().unwrap();
8337        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8338        let working: Arc<dyn LLMProvider> = Arc::new(WorkingProvider);
8339        state
8340            .provider_registry
8341            .insert("working".to_string(), working.clone());
8342        state.provider_registry.set_default("working".to_string());
8343        *state.provider.write().await = working.clone();
8344        state.config.write().await.provider = "openai".to_string();
8345        wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
8346        let mut feed = state.account_sink.subscribe();
8347        let providers_path = dir.path().join("providers.json");
8348
8349        std::fs::write(&providers_path, b"{broken").unwrap();
8350        tokio::time::timeout(Duration::from_secs(3), async {
8351            loop {
8352                if state
8353                    .config_live_health
8354                    .read()
8355                    .unwrap_or_else(|poisoned| poisoned.into_inner())
8356                    .status
8357                    == SectionStatus::Invalid
8358                {
8359                    break;
8360                }
8361                tokio::time::sleep(Duration::from_millis(20)).await;
8362            }
8363        })
8364        .await
8365        .unwrap();
8366        assert_eq!(
8367            state
8368                .config_live_health
8369                .read()
8370                .unwrap_or_else(|poisoned| poisoned.into_inner())
8371                .revision,
8372            0,
8373            "invalid edits must not advance the LKG revision"
8374        );
8375        {
8376            let health = state
8377                .config_live_health
8378                .read()
8379                .unwrap_or_else(|poisoned| poisoned.into_inner());
8380            assert_eq!(health.status, SectionStatus::Invalid);
8381            assert_eq!(health.source_kind, SectionSourceKind::File);
8382            assert_eq!(health.source_path, providers_path);
8383        }
8384        assert!(Arc::ptr_eq(
8385            &state.provider_registry.get_default().unwrap(),
8386            &working
8387        ));
8388        let invalid = next_config_event(&mut feed, "providers").await;
8389        assert!(matches!(
8390            invalid,
8391            AgentEvent::ConfigInvalid { revision: 0, .. }
8392        ));
8393
8394        let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
8395        let credential_store = bamboo_config::CredentialStore::open(dir.path());
8396        let credential_revision = credential_store.revision().unwrap();
8397        credential_store
8398            .replace(
8399                reference.clone(),
8400                "watcher-test-key",
8401                bamboo_config::CredentialSource::User,
8402                credential_revision,
8403            )
8404            .unwrap();
8405        let providers = ProviderConfigs {
8406            openai: Some(bamboo_config::OpenAIConfig {
8407                credential_ref: Some(reference),
8408                ..Default::default()
8409            }),
8410            ..Default::default()
8411        };
8412        std::fs::write(
8413            &providers_path,
8414            serde_json::to_vec_pretty(&providers).unwrap(),
8415        )
8416        .unwrap();
8417        tokio::time::timeout(Duration::from_secs(3), async {
8418            loop {
8419                let health = state
8420                    .config_live_health
8421                    .read()
8422                    .unwrap_or_else(|poisoned| poisoned.into_inner())
8423                    .clone();
8424                if health.status == SectionStatus::Healthy && health.revision == 1 {
8425                    break;
8426                }
8427                tokio::time::sleep(Duration::from_millis(20)).await;
8428            }
8429        })
8430        .await
8431        .unwrap();
8432        let recovered = next_config_event(&mut feed, "providers").await;
8433        assert!(matches!(
8434            recovered,
8435            AgentEvent::ConfigRecovered { revision: 1, .. }
8436        ));
8437        assert_eq!(state.provider_registry.default_provider_name(), "openai");
8438    }
8439
8440    #[tokio::test]
8441    async fn ordinary_section_watcher_updates_runtime_retains_lkg_and_recovers() {
8442        let _key = bamboo_config::encryption::set_test_encryption_key([0x44; 32]);
8443        let dir = tempfile::tempdir().unwrap();
8444        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8445        wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
8446        let mut feed = state.account_sink.subscribe();
8447        let path = dir.path().join("core.json");
8448        let mut document: serde_json::Value =
8449            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
8450        document["revision"] = serde_json::json!(2);
8451        document["data"]["server"]["port"] = serde_json::json!(9876);
8452        std::fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
8453
8454        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 2).await;
8455        tokio::time::timeout(Duration::from_secs(3), async {
8456            loop {
8457                if state.config.read().await.server.port == 9876 {
8458                    break;
8459                }
8460                tokio::time::sleep(Duration::from_millis(20)).await;
8461            }
8462        })
8463        .await
8464        .unwrap();
8465        assert!(matches!(
8466            next_config_event(&mut feed, "core").await,
8467            AgentEvent::ConfigChanged { revision: 2, .. }
8468        ));
8469
8470        std::fs::write(&path, b"{broken").unwrap();
8471        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Invalid, 2).await;
8472        assert_eq!(state.config.read().await.server.port, 9876);
8473        assert!(matches!(
8474            next_config_event(&mut feed, "core").await,
8475            AgentEvent::ConfigInvalid { revision: 2, .. }
8476        ));
8477
8478        document["revision"] = serde_json::json!(3);
8479        document["data"]["server"]["port"] = serde_json::json!(9877);
8480        std::fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
8481        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 3).await;
8482        tokio::time::timeout(Duration::from_secs(3), async {
8483            loop {
8484                if state.config.read().await.server.port == 9877 {
8485                    break;
8486                }
8487                tokio::time::sleep(Duration::from_millis(20)).await;
8488            }
8489        })
8490        .await
8491        .unwrap();
8492        assert!(matches!(
8493            next_config_event(&mut feed, "core").await,
8494            AgentEvent::ConfigRecovered { revision: 3, .. }
8495        ));
8496
8497        std::fs::remove_file(&path).unwrap();
8498        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Missing, 3).await;
8499        assert_eq!(state.config.read().await.server.port, 9877);
8500        assert!(matches!(
8501            next_config_event(&mut feed, "core").await,
8502            AgentEvent::ConfigInvalid { revision: 3, .. }
8503        ));
8504
8505        document["revision"] = serde_json::json!(4);
8506        document["data"]["server"]["port"] = serde_json::json!(9878);
8507        let swap = dir.path().join("core.json.swap");
8508        std::fs::write(&swap, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
8509        std::fs::rename(&swap, &path).unwrap();
8510        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 4).await;
8511        tokio::time::timeout(Duration::from_secs(3), async {
8512            loop {
8513                if state.config.read().await.server.port == 9878 {
8514                    break;
8515                }
8516                tokio::time::sleep(Duration::from_millis(20)).await;
8517            }
8518        })
8519        .await
8520        .unwrap();
8521        assert!(matches!(
8522            next_config_event(&mut feed, "core").await,
8523            AgentEvent::ConfigRecovered { revision: 4, .. }
8524        ));
8525    }
8526
8527    #[tokio::test]
8528    async fn mcp_watcher_updates_lkg_rejects_invalid_and_recovers_after_atomic_replace() {
8529        let _key = bamboo_config::encryption::set_test_encryption_key([0x45; 32]);
8530        let dir = tempfile::tempdir().unwrap();
8531        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8532        wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
8533        let mut feed = state.account_sink.subscribe();
8534        let path = dir.path().join("mcp.json");
8535
8536        std::fs::write(&path, mcp_document_bytes(2, &disabled_mcp_config("first"))).unwrap();
8537        let first = wait_for_mcp_health(&state, SectionStatus::Healthy, 2).await;
8538        assert_eq!(first.revision, 2);
8539        assert_eq!(state.config.read().await.mcp.servers[0].id, "first");
8540        assert!(matches!(
8541            next_mcp_config_event(&mut feed).await,
8542            AgentEvent::ConfigChanged { revision: 2, .. }
8543        ));
8544
8545        std::fs::write(&path, b"{broken").unwrap();
8546        let invalid = wait_for_mcp_health(&state, SectionStatus::Invalid, 2).await;
8547        assert_eq!(invalid.revision, 2, "invalid candidates cannot advance LKG");
8548        assert_eq!(state.config.read().await.mcp.servers[0].id, "first");
8549        assert!(matches!(
8550            next_mcp_config_event(&mut feed).await,
8551            AgentEvent::ConfigInvalid { revision: 2, .. }
8552        ));
8553
8554        // Model an editor's temp-write + atomic rename, with an immediate
8555        // follow-up write in the same debounce burst. The watcher must settle
8556        // on the final complete document rather than treating the rename gap as
8557        // a reset.
8558        let swap = dir.path().join("mcp.json.swap");
8559        std::fs::write(
8560            &swap,
8561            mcp_document_bytes(3, &disabled_mcp_config("intermediate")),
8562        )
8563        .unwrap();
8564        std::fs::rename(&swap, &path).unwrap();
8565        std::fs::write(
8566            &path,
8567            mcp_document_bytes(3, &disabled_mcp_config("recovered")),
8568        )
8569        .unwrap();
8570        let recovered = wait_for_mcp_health(&state, SectionStatus::Healthy, 3).await;
8571        assert_eq!(recovered.revision, 3, "rename burst should coalesce once");
8572        assert_eq!(state.config.read().await.mcp.servers[0].id, "recovered");
8573        assert!(matches!(
8574            next_mcp_config_event(&mut feed).await,
8575            AgentEvent::ConfigRecovered { revision: 3, .. }
8576        ));
8577
8578        // Reusing the live revision with different content forces the shared
8579        // store to normalize it to revision 4. The normalization write itself
8580        // must be suppressed exactly once rather than triggering a duplicate
8581        // reconcile/event.
8582        std::fs::write(
8583            &path,
8584            mcp_document_bytes(3, &disabled_mcp_config("normalized")),
8585        )
8586        .unwrap();
8587        let normalized = wait_for_mcp_health(&state, SectionStatus::Healthy, 4).await;
8588        assert_eq!(normalized.revision, 4);
8589        assert_eq!(state.config.read().await.mcp.servers[0].id, "normalized");
8590        assert!(matches!(
8591            next_mcp_config_event(&mut feed).await,
8592            AgentEvent::ConfigChanged { revision: 4, .. }
8593        ));
8594        assert!(
8595            tokio::time::timeout(Duration::from_millis(500), feed.recv())
8596                .await
8597                .is_err()
8598        );
8599        let persisted: serde_json::Value =
8600            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
8601        assert_eq!(persisted["revision"], 4);
8602    }
8603
8604    #[tokio::test]
8605    async fn mcp_sidecar_present_at_startup_is_applied_through_runtime_transaction() {
8606        let _key = bamboo_config::encryption::set_test_encryption_key([0x47; 32]);
8607        let dir = tempfile::tempdir().unwrap();
8608        std::fs::write(
8609            dir.path().join("mcp.json"),
8610            mcp_document_bytes(1, &disabled_mcp_config("startup-sidecar")),
8611        )
8612        .unwrap();
8613
8614        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8615        let health = wait_for_mcp_health(&state, SectionStatus::Healthy, 1).await;
8616        assert_eq!(health.revision, 1);
8617        assert_eq!(health.source_kind, SectionSourceKind::File);
8618        assert_eq!(
8619            state.config.read().await.mcp.servers[0].id,
8620            "startup-sidecar"
8621        );
8622    }
8623
8624    #[tokio::test]
8625    async fn mcp_startup_uses_valid_backup_and_reports_degraded_invalid_health() {
8626        let _key = bamboo_config::encryption::set_test_encryption_key([0x48; 32]);
8627        let dir = tempfile::tempdir().unwrap();
8628        let path = dir.path().join("mcp.json");
8629        let store = AtomicJsonStore::new(&path, 1);
8630        store
8631            .commit(0, disabled_mcp_config("backup-lkg"), validate_mcp_config)
8632            .unwrap();
8633        store
8634            .commit(1, disabled_mcp_config("new-primary"), validate_mcp_config)
8635            .unwrap();
8636        std::fs::write(&path, b"{corrupt-primary").unwrap();
8637
8638        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8639        let health = wait_for_mcp_health(&state, SectionStatus::Degraded, 1).await;
8640        assert_eq!(health.revision, 1);
8641        assert_eq!(health.source_kind, SectionSourceKind::Backup);
8642        assert_eq!(health.source_path, path.with_extension("json.bak"));
8643        assert!(health
8644            .last_error
8645            .as_deref()
8646            .unwrap()
8647            .contains("last-known-good backup runtime"));
8648        assert_eq!(state.config.read().await.mcp.servers[0].id, "backup-lkg");
8649
8650        tokio::time::timeout(Duration::from_secs(3), async {
8651            loop {
8652                if state.account_sink.latest_seq() > 0 {
8653                    break;
8654                }
8655                tokio::time::sleep(Duration::from_millis(20)).await;
8656            }
8657        })
8658        .await
8659        .unwrap();
8660        tokio::time::sleep(Duration::from_millis(500)).await;
8661        let events =
8662            bamboo_engine::events::journal::read_since(state.account_sink.events_dir(), 0).unwrap();
8663        assert_eq!(
8664            events
8665                .iter()
8666                .filter(|event| matches!(
8667                    &event.event,
8668                    AgentEvent::ConfigInvalid { section, revision }
8669                        if section == "mcp" && *revision == 1
8670                ))
8671                .count(),
8672            1
8673        );
8674        let stable_health = state
8675            .mcp_config_live_health
8676            .read()
8677            .unwrap_or_else(|poisoned| poisoned.into_inner())
8678            .clone();
8679        assert_eq!(stable_health.status, SectionStatus::Degraded);
8680        assert_eq!(stable_health.source_kind, SectionSourceKind::Backup);
8681        assert_eq!(stable_health.source_path, path.with_extension("json.bak"));
8682    }
8683
8684    #[tokio::test]
8685    async fn mcp_runtime_init_failure_marks_degraded_and_retains_lkg_config() {
8686        let _key = bamboo_config::encryption::set_test_encryption_key([0x46; 32]);
8687        let dir = tempfile::tempdir().unwrap();
8688        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8689        wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
8690        let mut feed = state.account_sink.subscribe();
8691        let path = dir.path().join("mcp.json");
8692
8693        std::fs::write(
8694            &path,
8695            mcp_document_bytes(1, &disabled_mcp_config("last-known-good")),
8696        )
8697        .unwrap();
8698        wait_for_mcp_health(&state, SectionStatus::Healthy, 1).await;
8699        let _ = next_mcp_config_event(&mut feed).await;
8700
8701        let mut failing = disabled_mcp_config("candidate");
8702        failing.servers[0].enabled = true;
8703        if let TransportConfig::Stdio(stdio) = &mut failing.servers[0].transport {
8704            stdio.command = "definitely-not-a-real-mcp-command-597".to_string();
8705        }
8706        std::fs::write(&path, mcp_document_bytes(2, &failing)).unwrap();
8707
8708        let degraded = wait_for_mcp_health(&state, SectionStatus::Degraded, 1).await;
8709        assert_eq!(degraded.revision, 1);
8710        assert!(degraded
8711            .last_error
8712            .as_deref()
8713            .unwrap()
8714            .contains("last-known-good runtime"));
8715        assert_eq!(
8716            state.config.read().await.mcp.servers[0].id,
8717            "last-known-good"
8718        );
8719        assert!(state.mcp_manager.list_servers().is_empty());
8720        assert!(matches!(
8721            next_mcp_config_event(&mut feed).await,
8722            AgentEvent::ConfigInvalid { revision: 1, .. }
8723        ));
8724    }
8725}