Skip to main content

bamboo_server/app_state/
config_runtime.rs

1use super::*;
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
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
18struct FacadeRuntimeMaterialization {
19    config: Config,
20    failures: BTreeSet<SectionId>,
21}
22
23fn materialize_facade_effective_config(
24    facade: &bamboo_config::ConfigFacade,
25    data_dir: &Path,
26) -> FacadeRuntimeMaterialization {
27    let mut config = facade.effective_config();
28    let mut failures = BTreeSet::new();
29    if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
30        tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
31        config.proxy_auth = None;
32        failures.insert(SectionId::Core);
33    }
34    if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
35        tracing::warn!(error = %error, "provider credential hydration unavailable");
36        failures.insert(SectionId::Providers);
37    }
38    if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
39        tracing::warn!(error = %error, "MCP credential hydration unavailable");
40        failures.insert(SectionId::Mcp);
41    }
42    if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
43        tracing::warn!(error = %error, "env credential hydration unavailable");
44        for entry in &mut config.env_vars {
45            if entry.secret {
46                entry.value.clear();
47            }
48        }
49        failures.insert(SectionId::Env);
50    }
51    if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
52        tracing::warn!(error = %error, "cluster credential hydration unavailable");
53        failures.insert(SectionId::ClusterFabric);
54    }
55    if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
56        tracing::warn!(error = %error, "notification credential hydration unavailable");
57        config.notifications.ntfy.token = None;
58        config.notifications.bark.device_key = None;
59        failures.insert(SectionId::Notifications);
60    }
61    if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
62        tracing::warn!(error = %error, "connect credential hydration unavailable");
63        for platform in &mut config.connect.platforms {
64            platform.token = None;
65            platform.app_secret = None;
66        }
67        failures.insert(SectionId::Connect);
68    }
69    if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
70        tracing::warn!(error = %error, "access-control credential hydration unavailable");
71        config.clear_access_control_runtime_verifiers();
72        failures.insert(SectionId::AccessControl);
73    }
74    if let Some(broker) = config.subagents_mut().broker.as_mut() {
75        if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
76            tracing::warn!(error = %error, "external broker credential hydration unavailable");
77            broker.token.clear();
78            failures.insert(SectionId::Subagents);
79        }
80    }
81    config.apply_runtime_env_overrides();
82    FacadeRuntimeMaterialization { config, failures }
83}
84
85pub(super) fn load_facade_effective_config(
86    facade: &bamboo_config::ConfigFacade,
87    data_dir: &Path,
88) -> Config {
89    materialize_facade_effective_config(facade, data_dir).config
90}
91
92fn load_committed_effective_config(data_dir: &Path) -> Result<Config, ConfigStoreError> {
93    if bamboo_config::section_layout_is_active(data_dir)? {
94        let facade = bamboo_config::ConfigFacade::open(data_dir)?;
95        let config = load_facade_effective_config(&facade, data_dir);
96        Ok(config)
97    } else {
98        Ok(Config::from_data_dir_without_publish(Some(
99            data_dir.to_path_buf(),
100        )))
101    }
102}
103
104/// Health metadata for the server-owned effective/provider configuration view.
105#[derive(Debug, Clone, Serialize)]
106pub struct ConfigLiveHealth {
107    pub revision: u64,
108    pub loaded_at: DateTime<Utc>,
109    pub source_path: PathBuf,
110    pub source_kind: SectionSourceKind,
111    pub status: SectionStatus,
112    pub last_error: Option<String>,
113}
114
115/// Owns the blocking filesystem watcher and async runtime-apply task.
116pub struct ConfigWatcherRuntime {
117    stop: Arc<AtomicBool>,
118    watcher_task: Option<std::thread::JoinHandle<()>>,
119    apply_task: Option<tokio::task::JoinHandle<()>>,
120}
121
122struct ConfigPathChanges {
123    paths: Vec<PathBuf>,
124    initial_mcp: bool,
125}
126
127impl ConfigWatcherRuntime {
128    #[allow(clippy::too_many_arguments)]
129    pub fn start(
130        data_dir: PathBuf,
131        config: Arc<RwLock<Config>>,
132        config_facade: Option<Arc<bamboo_config::ConfigFacade>>,
133        config_io_lock: Arc<tokio::sync::Mutex<()>>,
134        provider_registry: Arc<bamboo_llm::ProviderRegistry>,
135        provider: Arc<RwLock<Arc<dyn LLMProvider>>>,
136        mcp_manager: Arc<McpServerManager>,
137        account_sink: Arc<bamboo_engine::events::AccountEventSink>,
138    ) -> (
139        Self,
140        Arc<std::sync::RwLock<ConfigLiveHealth>>,
141        Arc<std::sync::RwLock<ConfigLiveHealth>>,
142    ) {
143        let provider_store = AtomicJsonStore::new(data_dir.join("providers.json"), 1);
144        let provider_health = Arc::new(std::sync::RwLock::new(initial_provider_health(
145            &provider_store,
146        )));
147        let mcp_store = AtomicJsonStore::new(data_dir.join("mcp.json"), 1);
148        let mcp_health = Arc::new(std::sync::RwLock::new(initial_mcp_health(&mcp_store)));
149        let stop = Arc::new(AtomicBool::new(false));
150        let watcher = match ConfigDirectoryWatcher::watch(&data_dir, Duration::from_millis(120)) {
151            Ok(watcher) => watcher,
152            Err(error) => {
153                tracing::warn!(error = %error, "live configuration watcher could not start");
154                {
155                    let mut value = provider_health
156                        .write()
157                        .unwrap_or_else(|poisoned| poisoned.into_inner());
158                    value.status = SectionStatus::Degraded;
159                    value.last_error = Some("configuration watcher unavailable".to_string());
160                }
161                {
162                    let mut value = mcp_health
163                        .write()
164                        .unwrap_or_else(|poisoned| poisoned.into_inner());
165                    value.status = SectionStatus::Degraded;
166                    value.last_error = Some("configuration watcher unavailable".to_string());
167                }
168                return (
169                    Self {
170                        stop,
171                        watcher_task: None,
172                        apply_task: None,
173                    },
174                    provider_health,
175                    mcp_health,
176                );
177            }
178        };
179
180        // The filesystem side must never block in send: Drop joins this OS
181        // thread after aborting the async consumer, so a bounded blocking_send
182        // could deadlock shutdown if its queue were full.
183        let self_write_marker = watcher.self_write_marker();
184        let (changes_tx, mut changes_rx) =
185            tokio::sync::mpsc::unbounded_channel::<ConfigPathChanges>();
186        let initial_changes = changes_tx.clone();
187        let worker_stop = stop.clone();
188        let watcher_task = std::thread::spawn(move || {
189            while !worker_stop.load(Ordering::Relaxed) {
190                match watcher.recv_timeout(Duration::from_millis(250)) {
191                    Ok(paths) => {
192                        if changes_tx
193                            .send(ConfigPathChanges {
194                                paths,
195                                initial_mcp: false,
196                            })
197                            .is_err()
198                        {
199                            break;
200                        }
201                    }
202                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
203                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
204                }
205            }
206        });
207
208        // A sidecar may already exist before the server starts. Queue it through
209        // the exact same candidate/runtime transaction as later filesystem
210        // events; parse-only initial health must never be mistaken for a
211        // published runtime snapshot.
212        let initial_mcp_path = data_dir.join("mcp.json");
213        if initial_mcp_path.exists() {
214            let _ = initial_changes.send(ConfigPathChanges {
215                paths: vec![initial_mcp_path],
216                initial_mcp: true,
217            });
218        }
219
220        let apply_provider_health = provider_health.clone();
221        let apply_mcp_health = mcp_health.clone();
222        let apply_task = tokio::spawn(async move {
223            while let Some(changes) = changes_rx.recv().await {
224                let watched_sections = config_facade
225                    .as_ref()
226                    .map(|_| {
227                        changes
228                            .paths
229                            .iter()
230                            .filter_map(|path| {
231                                path.file_name()
232                                    .and_then(|name| name.to_str())
233                                    .and_then(SectionId::from_file_name)
234                            })
235                            .collect::<BTreeSet<_>>()
236                    })
237                    .unwrap_or_default();
238                let provider_watched = changes.paths.iter().any(|path| {
239                    path.file_name().and_then(|name| name.to_str()) == Some("providers.json")
240                });
241                let mcp_watched = changes.paths.iter().any(|path| {
242                    path.file_name().and_then(|name| name.to_str()) == Some("mcp.json")
243                });
244                let ordinary_watched = watched_sections
245                    .iter()
246                    .copied()
247                    .filter(|id| !matches!(id, SectionId::Providers | SectionId::Mcp))
248                    .collect::<Vec<_>>();
249                if !provider_watched && !mcp_watched && ordinary_watched.is_empty() {
250                    continue;
251                }
252
253                // Serialize candidate construction and publication with config
254                // writers. Otherwise a slow provider build could later publish
255                // a clone taken before an unrelated API update and clobber it.
256                let _io = config_io_lock.lock().await;
257                if let Some(facade) = config_facade.as_ref() {
258                    let mut publishable = Vec::new();
259                    for id in ordinary_watched {
260                        wait_for_section_file_settle(&data_dir, id).await;
261                        let Some(event) = facade.registry().reload_if_changed(id) else {
262                            continue;
263                        };
264                        if matches!(event, ConfigSectionEvent::Invalid { .. }) {
265                            publish_registry_event(&account_sink, &event);
266                        } else {
267                            publishable.push((id, event));
268                        }
269                    }
270                    if !publishable.is_empty() {
271                        let materialized = materialize_facade_effective_config(facade, &data_dir);
272                        let mut current = config.read().await.clone();
273                        let mut applied = Vec::new();
274                        for (id, event) in publishable {
275                            if materialized.failures.contains(&id) {
276                                if let Some(invalid) = facade.registry().mark_runtime_degraded(
277                                    id,
278                                    "configuration runtime hydration failed; retaining last-known-good runtime",
279                                ) {
280                                    publish_registry_event(&account_sink, &invalid);
281                                }
282                                continue;
283                            }
284                            apply_runtime_section(id, &materialized.config, &mut current);
285                            applied.push((id, event));
286                        }
287                        if !applied.is_empty() {
288                            let publishes_env = applied.iter().any(|(id, _)| *id == SectionId::Env);
289                            let enforcement_newly_off =
290                                !config.read().await.plugin_trust.enforcement_is_off()
291                                    && current.plugin_trust.enforcement_is_off();
292                            *config.write().await = current.clone();
293                            if publishes_env {
294                                current.publish_env_vars();
295                            }
296                            if enforcement_newly_off {
297                                warn_plugin_trust_enforcement_off();
298                            }
299                            for (_, event) in applied {
300                                publish_registry_event(&account_sink, &event);
301                            }
302                        }
303                    }
304                }
305                if provider_watched {
306                    if let Some(facade) = config_facade.as_ref() {
307                        wait_for_section_file_settle(&data_dir, SectionId::Providers).await;
308                        if let Some(event) =
309                            facade.registry().reload_if_changed(SectionId::Providers)
310                        {
311                            if matches!(event, ConfigSectionEvent::Invalid { .. }) {
312                                publish_section_failure(
313                                &apply_provider_health,
314                                &account_sink,
315                                "providers",
316                                facade.registry().providers.snapshot().status,
317                                "provider section is invalid; retaining last-known-good runtime"
318                                    .to_string(),
319                            );
320                            } else {
321                                self_write_marker.mark_self_write(provider_store.path());
322                                let materialized =
323                                    materialize_facade_effective_config(facade, &data_dir);
324                                if materialized.failures.contains(&SectionId::Providers) {
325                                    facade.registry().mark_runtime_degraded(
326                                    SectionId::Providers,
327                                    "provider credential hydration failed; retaining last-known-good runtime",
328                                );
329                                    publish_section_failure(
330                                    &apply_provider_health,
331                                    &account_sink,
332                                    "providers",
333                                    SectionStatus::Degraded,
334                                    "provider credential hydration failed; retaining last-known-good runtime"
335                                        .to_string(),
336                                );
337                                } else {
338                                    let mut candidate = config.read().await.clone();
339                                    apply_runtime_section(
340                                        SectionId::Providers,
341                                        &materialized.config,
342                                        &mut candidate,
343                                    );
344                                    match prepare_provider_candidate(candidate, &data_dir).await {
345                                        Ok((candidate, registry, next_provider)) => {
346                                            let mut live_config = config.write().await;
347                                            let mut live_provider = provider.write().await;
348                                            let recovered =
349                                                section_is_unhealthy(&apply_provider_health);
350                                            candidate.publish_env_vars();
351                                            *live_config = candidate;
352                                            provider_registry.replace_with(registry);
353                                            *live_provider = next_provider;
354                                            drop(live_provider);
355                                            drop(live_config);
356                                            let revision =
357                                                facade.registry().providers.snapshot().revision;
358                                            publish_section_success(
359                                                &apply_provider_health,
360                                                &account_sink,
361                                                "providers",
362                                                data_dir.join("providers.json"),
363                                                recovered,
364                                                Some(revision),
365                                            );
366                                        }
367                                        Err(_) => {
368                                            facade.registry().mark_runtime_degraded(
369                                            SectionId::Providers,
370                                            "provider runtime initialization failed; retaining last-known-good runtime",
371                                        );
372                                            publish_section_failure(
373                                            &apply_provider_health,
374                                            &account_sink,
375                                            "providers",
376                                            SectionStatus::Degraded,
377                                            "provider runtime initialization failed; retaining last-known-good runtime"
378                                                .to_string(),
379                                        );
380                                        }
381                                    }
382                                }
383                            }
384                        }
385                    } else {
386                        let current_config = config.read().await.clone();
387                        let current_revision = apply_provider_health
388                            .read()
389                            .unwrap_or_else(|poisoned| poisoned.into_inner())
390                            .revision;
391                        let result = load_and_prepare_provider_candidate(
392                            &provider_store,
393                            current_revision,
394                            current_config,
395                        )
396                        .await;
397                        match result {
398                            Ok(candidate) if candidate.unchanged => {}
399                            Ok(candidate) => {
400                                if candidate.normalized_external_revision {
401                                    self_write_marker.mark_self_write(provider_store.path());
402                                }
403                                let mut live_config = config.write().await;
404                                let mut live_provider = provider.write().await;
405                                let recovered = section_is_unhealthy(&apply_provider_health);
406                                candidate.config.publish_env_vars();
407                                *live_config = candidate.config;
408                                provider_registry.replace_with(candidate.registry);
409                                *live_provider = candidate.provider;
410                                drop(live_provider);
411                                drop(live_config);
412
413                                publish_section_success(
414                                    &apply_provider_health,
415                                    &account_sink,
416                                    "providers",
417                                    data_dir.join("providers.json"),
418                                    recovered,
419                                    Some(candidate.revision),
420                                );
421                            }
422                            Err(error) => publish_section_failure(
423                                &apply_provider_health,
424                                &account_sink,
425                                "providers",
426                                candidate_error_status(&error.kind),
427                                error.message,
428                            ),
429                        }
430                    }
431                }
432
433                if mcp_watched {
434                    if let Some(facade) = config_facade.as_ref() {
435                        wait_for_section_file_settle(&data_dir, SectionId::Mcp).await;
436                        let event =
437                            facade
438                                .registry()
439                                .reload_if_changed(SectionId::Mcp)
440                                .or_else(|| {
441                                    changes.initial_mcp.then(|| ConfigSectionEvent::Changed {
442                                        section: "mcp".to_string(),
443                                        revision: facade.registry().mcp.snapshot().revision,
444                                    })
445                                });
446                        let Some(event) = event else {
447                            continue;
448                        };
449                        if matches!(event, ConfigSectionEvent::Invalid { .. }) {
450                            publish_section_failure(
451                                &apply_mcp_health,
452                                &account_sink,
453                                "mcp",
454                                facade.registry().mcp.snapshot().status,
455                                "MCP section is invalid; retaining last-known-good runtime"
456                                    .to_string(),
457                            );
458                        } else {
459                            self_write_marker.mark_self_write(mcp_store.path());
460                            let materialized =
461                                materialize_facade_effective_config(facade, &data_dir);
462                            if materialized.failures.contains(&SectionId::Mcp) {
463                                facade.registry().mark_runtime_degraded(
464                                    SectionId::Mcp,
465                                    "MCP credential hydration failed; retaining last-known-good runtime",
466                                );
467                                publish_section_failure(
468                                    &apply_mcp_health,
469                                    &account_sink,
470                                    "mcp",
471                                    SectionStatus::Degraded,
472                                    "MCP credential hydration failed; retaining last-known-good runtime"
473                                        .to_string(),
474                                );
475                                continue;
476                            }
477                            let next_mcp = materialized.config.mcp.clone();
478                            let publish_config = config.clone();
479                            match mcp_manager
480                                .reconcile_from_config_transactional_after(
481                                    &materialized.config.mcp,
482                                    || async move {
483                                        publish_config.write().await.mcp = next_mcp;
484                                        Ok(())
485                                    },
486                                )
487                                .await
488                            {
489                                Ok(()) => {
490                                    let recovered = section_is_unhealthy(&apply_mcp_health);
491                                    let snapshot = facade.registry().mcp.snapshot();
492                                    let revision = snapshot.revision;
493                                    if changes.initial_mcp
494                                        && snapshot.status == SectionStatus::Healthy
495                                    {
496                                        // Startup reconciliation makes the
497                                        // already-materialized section live in
498                                        // the MCP manager; it is not a config
499                                        // mutation and must not consume an
500                                        // account-feed sequence number.
501                                        set_live_health_revision(
502                                            &apply_mcp_health,
503                                            revision,
504                                            Some((
505                                                data_dir.join("mcp.json"),
506                                                SectionSourceKind::File,
507                                            )),
508                                        );
509                                    } else {
510                                        publish_section_success(
511                                            &apply_mcp_health,
512                                            &account_sink,
513                                            "mcp",
514                                            data_dir.join("mcp.json"),
515                                            recovered,
516                                            Some(revision),
517                                        );
518                                    }
519                                }
520                                Err(_) => {
521                                    facade.registry().mark_runtime_degraded(
522                                        SectionId::Mcp,
523                                        "MCP runtime initialization failed; retaining last-known-good runtime",
524                                    );
525                                    publish_section_failure(
526                                        &apply_mcp_health,
527                                        &account_sink,
528                                        "mcp",
529                                        SectionStatus::Degraded,
530                                        "MCP runtime initialization failed; retaining last-known-good runtime"
531                                            .to_string(),
532                                    )
533                                }
534                            }
535                        }
536                    } else {
537                        let current_config = config.read().await.clone();
538                        let current_revision = apply_mcp_health
539                            .read()
540                            .unwrap_or_else(|poisoned| poisoned.into_inner())
541                            .revision;
542                        let result = load_and_validate_mcp_candidate(
543                            &mcp_store,
544                            current_revision,
545                            current_config,
546                            changes.initial_mcp,
547                        )
548                        .await;
549                        match result {
550                            Ok(candidate) if candidate.unchanged => {}
551                            Ok(candidate) => {
552                                if candidate.normalized_external_revision
553                                    || candidate.source_kind == SectionSourceKind::Backup
554                                {
555                                    // Normalization writes the primary and backup
556                                    // recovery quarantines it. Suppress only that
557                                    // exact watcher echo; a later external write has
558                                    // a different fingerprint and remains visible.
559                                    self_write_marker.mark_self_write(mcp_store.path());
560                                }
561                                let next_mcp = candidate.config.mcp.clone();
562                                let publish_config = config.clone();
563                                match mcp_manager
564                                    .reconcile_from_config_transactional_after(
565                                        &candidate.config.mcp,
566                                        || async move {
567                                            publish_config.write().await.mcp = next_mcp;
568                                            Ok(())
569                                        },
570                                    )
571                                    .await
572                                {
573                                    Ok(()) => {
574                                        let recovered = section_is_unhealthy(&apply_mcp_health);
575                                        if candidate.source_kind == SectionSourceKind::Backup {
576                                            publish_mcp_backup_lkg(
577                                                &apply_mcp_health,
578                                                &account_sink,
579                                                candidate.source_path,
580                                                candidate.revision,
581                                            );
582                                        } else {
583                                            publish_section_success(
584                                                &apply_mcp_health,
585                                                &account_sink,
586                                                "mcp",
587                                                data_dir.join("mcp.json"),
588                                                recovered,
589                                                Some(candidate.revision),
590                                            );
591                                        }
592                                    }
593                                    Err(_) => publish_section_failure(
594                                        &apply_mcp_health,
595                                        &account_sink,
596                                        "mcp",
597                                        SectionStatus::Degraded,
598                                        "MCP runtime initialization failed; retaining last-known-good runtime"
599                                            .to_string(),
600                                    ),
601                                }
602                            }
603                            Err(error) => publish_section_failure(
604                                &apply_mcp_health,
605                                &account_sink,
606                                "mcp",
607                                candidate_error_status(&error.kind),
608                                error.message,
609                            ),
610                        }
611                    }
612                }
613            }
614        });
615
616        (
617            Self {
618                stop,
619                watcher_task: Some(watcher_task),
620                apply_task: Some(apply_task),
621            },
622            provider_health,
623            mcp_health,
624        )
625    }
626}
627
628async fn wait_for_section_file_settle(data_dir: &Path, id: SectionId) {
629    let path = data_dir.join(id.descriptor().file_name);
630    for _ in 0..3 {
631        if path.exists() {
632            return;
633        }
634        tokio::time::sleep(Duration::from_millis(50)).await;
635    }
636}
637
638fn publish_registry_event(
639    account_sink: &bamboo_engine::events::AccountEventSink,
640    event: &ConfigSectionEvent,
641) {
642    let event = match event {
643        ConfigSectionEvent::Changed { section, revision } => AgentEvent::ConfigChanged {
644            section: section.clone(),
645            revision: *revision,
646        },
647        ConfigSectionEvent::Invalid { section, revision } => AgentEvent::ConfigInvalid {
648            section: section.clone(),
649            revision: *revision,
650        },
651        ConfigSectionEvent::Recovered { section, revision } => AgentEvent::ConfigRecovered {
652            section: section.clone(),
653            revision: *revision,
654        },
655    };
656    account_sink.record(None, &event);
657}
658
659fn publish_exact_facade_commit(
660    facade: Option<&std::sync::Arc<bamboo_config::ConfigFacade>>,
661    account_sink: &bamboo_engine::events::AccountEventSink,
662    sections: &[SectionId],
663) -> Result<(), AppError> {
664    let Some(facade) = facade else {
665        return Ok(());
666    };
667    for section in sections {
668        let Some(event) = facade.registry().reload_if_changed(*section) else {
669            continue;
670        };
671        publish_registry_event(account_sink, &event);
672        if matches!(event, ConfigSectionEvent::Invalid { .. }) {
673            return Err(AppError::InternalError(anyhow::anyhow!(
674                "committed configuration section became invalid before publication"
675            )));
676        }
677    }
678    Ok(())
679}
680
681fn apply_runtime_section(id: SectionId, source: &Config, target: &mut Config) {
682    match id {
683        SectionId::Core => {
684            target.http_proxy = source.http_proxy.clone();
685            target.https_proxy = source.https_proxy.clone();
686            target.proxy_auth = source.proxy_auth.clone();
687            target.proxy_auth_encrypted = None;
688            target.proxy_auth_credential_ref = source.proxy_auth_credential_ref.clone();
689            target.headless_auth = source.headless_auth;
690            target.server = source.server.clone();
691            target.default_work_area = source.default_work_area.clone();
692            target.run_budget = source.run_budget;
693            target.stream_timeout = source.stream_timeout;
694            target.extra = source.extra.clone();
695        }
696        SectionId::Providers => {
697            target.provider = source.provider.clone();
698            target.defaults = source.defaults.clone();
699            target.provider_instances = source.provider_instances.clone();
700            target.default_provider_instance = source.default_provider_instance.clone();
701            target.features = source.features.clone();
702            *target.providers_mut() = source.providers().clone();
703        }
704        SectionId::Mcp => target.mcp = source.mcp.clone(),
705        SectionId::ToolsSkills => {
706            target.tools = source.tools.clone();
707            target.skills = source.skills.clone();
708            target.plugin_trust = source.plugin_trust.clone();
709        }
710        SectionId::Memory => *target.memory_mut() = source.memory().clone(),
711        SectionId::Subagents => *target.subagents_mut() = source.subagents().clone(),
712        SectionId::Notifications => target.notifications = source.notifications.clone(),
713        SectionId::Connect => target.connect = source.connect.clone(),
714        SectionId::ClusterFabric => target.cluster_fabric = source.cluster_fabric.clone(),
715        SectionId::Env => target.env_vars = source.env_vars.clone(),
716        SectionId::AccessControl => target.access_control = source.access_control.clone(),
717        SectionId::Hooks => target.hooks = source.hooks.clone(),
718        SectionId::ModelPolicy => {
719            target.keyword_masking = source.keyword_masking.clone();
720            target.anthropic_model_mapping = source.anthropic_model_mapping.clone();
721            target.gemini_model_mapping = source.gemini_model_mapping.clone();
722        }
723        SectionId::ModelLimits | SectionId::Credentials => {}
724    }
725}
726
727fn section_is_unhealthy(health: &std::sync::RwLock<ConfigLiveHealth>) -> bool {
728    health
729        .read()
730        .unwrap_or_else(|poisoned| poisoned.into_inner())
731        .status
732        != SectionStatus::Healthy
733}
734
735fn publish_section_success(
736    health: &std::sync::RwLock<ConfigLiveHealth>,
737    account_sink: &bamboo_engine::events::AccountEventSink,
738    section: &str,
739    source_path: PathBuf,
740    recovered: bool,
741    revision: Option<u64>,
742) {
743    let revision = match revision {
744        Some(revision) => set_live_health_revision(
745            health,
746            revision,
747            Some((source_path, SectionSourceKind::File)),
748        ),
749        None => update_live_health(
750            health,
751            SectionStatus::Healthy,
752            None,
753            true,
754            Some((source_path, SectionSourceKind::File)),
755        ),
756    };
757    let event = if recovered {
758        AgentEvent::ConfigRecovered {
759            section: section.to_string(),
760            revision,
761        }
762    } else {
763        AgentEvent::ConfigChanged {
764            section: section.to_string(),
765            revision,
766        }
767    };
768    account_sink.record(None, &event);
769}
770
771fn publish_section_failure(
772    health: &std::sync::RwLock<ConfigLiveHealth>,
773    account_sink: &bamboo_engine::events::AccountEventSink,
774    section: &str,
775    status: SectionStatus,
776    message: String,
777) {
778    let revision = update_live_health(health, status, Some(message), false, None);
779    account_sink.record(
780        None,
781        &AgentEvent::ConfigInvalid {
782            section: section.to_string(),
783            revision,
784        },
785    );
786}
787
788fn publish_mcp_backup_lkg(
789    health: &std::sync::RwLock<ConfigLiveHealth>,
790    account_sink: &bamboo_engine::events::AccountEventSink,
791    source_path: PathBuf,
792    revision: u64,
793) {
794    {
795        let mut health = health
796            .write()
797            .unwrap_or_else(|poisoned| poisoned.into_inner());
798        health.revision = revision;
799        health.loaded_at = Utc::now();
800        health.source_path = source_path;
801        health.source_kind = SectionSourceKind::Backup;
802        health.status = SectionStatus::Degraded;
803        health.last_error =
804            Some("primary MCP section invalid; running last-known-good backup runtime".to_string());
805    }
806    account_sink.record(
807        None,
808        &AgentEvent::ConfigInvalid {
809            section: "mcp".to_string(),
810            revision,
811        },
812    );
813}
814
815fn initial_provider_health(store: &AtomicJsonStore<ProviderConfigs>) -> ConfigLiveHealth {
816    if ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
817        .is_err()
818    {
819        return ConfigLiveHealth {
820            revision: 0,
821            loaded_at: Utc::now(),
822            source_path: store.path().to_path_buf(),
823            source_kind: SectionSourceKind::File,
824            status: SectionStatus::Degraded,
825            last_error: Some("provider/MCP credential migration is pending".to_string()),
826        };
827    }
828    match store.load_validated_allowing_unversioned(|_| Ok(())) {
829        Ok(Some(stored)) => ConfigLiveHealth {
830            revision: stored.revision,
831            loaded_at: Utc::now(),
832            source_path: stored.source_path,
833            source_kind: if stored.recovered_from_backup {
834                SectionSourceKind::Backup
835            } else {
836                SectionSourceKind::File
837            },
838            status: if stored.recovered_from_backup {
839                SectionStatus::Degraded
840            } else {
841                SectionStatus::Healthy
842            },
843            last_error: stored.recovered_from_backup.then(|| {
844                "primary provider section invalid; using last-known-good backup".to_string()
845            }),
846        },
847        Ok(None) => ConfigLiveHealth {
848            revision: 0,
849            loaded_at: Utc::now(),
850            source_path: store.path().to_path_buf(),
851            source_kind: SectionSourceKind::Default,
852            status: SectionStatus::Missing,
853            last_error: None,
854        },
855        Err(_) => ConfigLiveHealth {
856            revision: 0,
857            loaded_at: Utc::now(),
858            source_path: store.path().to_path_buf(),
859            source_kind: SectionSourceKind::File,
860            status: SectionStatus::Invalid,
861            last_error: Some("provider section could not be parsed or read".to_string()),
862        },
863    }
864}
865
866fn initial_mcp_health(store: &AtomicJsonStore<McpConfig>) -> ConfigLiveHealth {
867    if ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
868        .is_err()
869    {
870        return ConfigLiveHealth {
871            revision: 0,
872            loaded_at: Utc::now(),
873            source_path: store.path().to_path_buf(),
874            source_kind: SectionSourceKind::File,
875            status: SectionStatus::Degraded,
876            last_error: Some("provider/MCP credential migration is pending".to_string()),
877        };
878    }
879    match store.load_validated(validate_mcp_config) {
880        Ok(Some(stored)) => ConfigLiveHealth {
881            // The document is only a parsed candidate until runtime staging
882            // succeeds; the published LKG revision remains zero meanwhile.
883            revision: 0,
884            loaded_at: Utc::now(),
885            source_path: store.path().to_path_buf(),
886            source_kind: if stored.recovered_from_backup {
887                SectionSourceKind::Backup
888            } else {
889                SectionSourceKind::File
890            },
891            status: SectionStatus::Degraded,
892            last_error: Some(if stored.recovered_from_backup {
893                "primary MCP section invalid; runtime initialization pending from backup"
894                    .to_string()
895            } else {
896                "MCP runtime initialization pending".to_string()
897            }),
898        },
899        Ok(None) => ConfigLiveHealth {
900            revision: 0,
901            loaded_at: Utc::now(),
902            source_path: store.path().to_path_buf(),
903            source_kind: SectionSourceKind::Default,
904            status: SectionStatus::Missing,
905            last_error: None,
906        },
907        Err(_) => ConfigLiveHealth {
908            revision: 0,
909            loaded_at: Utc::now(),
910            source_path: store.path().to_path_buf(),
911            source_kind: SectionSourceKind::File,
912            status: SectionStatus::Invalid,
913            last_error: Some("MCP section could not be parsed or validated".to_string()),
914        },
915    }
916}
917
918impl Drop for ConfigWatcherRuntime {
919    fn drop(&mut self) {
920        self.stop.store(true, Ordering::Relaxed);
921        if let Some(task) = self.apply_task.take() {
922            task.abort();
923        }
924        if let Some(task) = self.watcher_task.take() {
925            let _ = task.join();
926        }
927    }
928}
929
930async fn load_and_prepare_provider_candidate(
931    store: &AtomicJsonStore<ProviderConfigs>,
932    current_revision: u64,
933    candidate_config: Config,
934) -> Result<ProviderCandidate, ProviderCandidateError> {
935    ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
936        .map_err(|_| {
937            ProviderCandidateError::invalid(
938                "provider/MCP credential migration is pending; retaining last-known-good runtime",
939            )
940        })?;
941    // Editors commonly implement save as delete/rename/create. Retry a missing
942    // watched file briefly instead of treating the transient gap as a reset.
943    for _ in 0..3 {
944        if store.path().exists() {
945            break;
946        }
947        tokio::time::sleep(Duration::from_millis(50)).await;
948    }
949    if !store.path().exists() {
950        return Err(ProviderCandidateError::missing());
951    }
952    let stored = store
953        .load_validated_for_reload_allowing_unversioned(
954            current_revision,
955            candidate_config.providers(),
956            validate_provider_config,
957        )
958        .map_err(|_| {
959            if store.path().exists() {
960                ProviderCandidateError::invalid("provider section is invalid")
961            } else {
962                ProviderCandidateError::missing()
963            }
964        })?
965        .ok_or_else(ProviderCandidateError::missing)?;
966    if stored.recovered_from_backup {
967        return Err(ProviderCandidateError::invalid(
968            "primary provider section is invalid; retaining last-known-good runtime",
969        ));
970    }
971    let unchanged = stored.revision == current_revision
972        && serde_json::to_value(&stored.data).ok()
973            == serde_json::to_value(candidate_config.providers()).ok();
974    let mut candidate_config = candidate_config;
975    *candidate_config.providers_mut() = stored.data;
976    let (candidate_config, registry, provider) = prepare_provider_candidate(
977        candidate_config,
978        store
979            .path()
980            .parent()
981            .unwrap_or_else(|| std::path::Path::new(".")),
982    )
983    .await?;
984    Ok(ProviderCandidate {
985        config: candidate_config,
986        registry,
987        provider,
988        revision: stored.revision,
989        normalized_external_revision: stored.normalized_external_revision,
990        unchanged,
991    })
992}
993
994async fn prepare_provider_candidate(
995    mut candidate_config: Config,
996    data_dir: &std::path::Path,
997) -> Result<(Config, bamboo_llm::ProviderRegistry, Arc<dyn LLMProvider>), ProviderCandidateError> {
998    candidate_config.hydrate_provider_api_keys_from_encrypted();
999    candidate_config
1000        .hydrate_provider_credentials_from_store(data_dir)
1001        .map_err(|_| ProviderCandidateError::invalid("provider credential is unavailable"))?;
1002    let candidate_registry =
1003        bamboo_llm::ProviderRegistry::from_config(&candidate_config, data_dir.to_path_buf())
1004            .await
1005            .map_err(|_| ProviderCandidateError::runtime())?;
1006    let candidate_provider = candidate_registry
1007        .get_default()
1008        .ok_or_else(ProviderCandidateError::runtime)?;
1009    Ok((candidate_config, candidate_registry, candidate_provider))
1010}
1011
1012struct ProviderCandidate {
1013    config: Config,
1014    registry: bamboo_llm::ProviderRegistry,
1015    provider: Arc<dyn LLMProvider>,
1016    revision: u64,
1017    normalized_external_revision: bool,
1018    unchanged: bool,
1019}
1020
1021async fn load_and_validate_mcp_candidate(
1022    store: &AtomicJsonStore<McpConfig>,
1023    current_revision: u64,
1024    mut candidate_config: Config,
1025    allow_startup_backup: bool,
1026) -> Result<McpCandidate, ProviderCandidateError> {
1027    ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
1028        .map_err(|_| {
1029            ProviderCandidateError::invalid(
1030                "provider/MCP credential migration is pending; retaining last-known-good runtime",
1031            )
1032        })?;
1033    for _ in 0..3 {
1034        if store.path().exists() {
1035            break;
1036        }
1037        tokio::time::sleep(Duration::from_millis(50)).await;
1038    }
1039    if !store.path().exists() {
1040        return Err(ProviderCandidateError::missing_section(
1041            "MCP section is missing",
1042        ));
1043    }
1044    let current_document = mcp_durable_comparison_document(&candidate_config.mcp);
1045    let stored = store
1046        .load_validated_for_reload(current_revision, &current_document, validate_mcp_config)
1047        .map_err(|_| ProviderCandidateError::invalid("MCP section is invalid"))?
1048        .ok_or_else(|| ProviderCandidateError::missing_section("MCP section is missing"))?;
1049    if stored.recovered_from_backup && !allow_startup_backup {
1050        return Err(ProviderCandidateError::invalid(
1051            "primary MCP section is invalid; retaining last-known-good runtime",
1052        ));
1053    }
1054    let unchanged = !allow_startup_backup
1055        && stored.revision == current_revision
1056        && serde_json::to_value(&stored.data).ok() == serde_json::to_value(&current_document).ok();
1057    candidate_config.mcp = stored.data;
1058    candidate_config.hydrate_mcp_secrets_from_encrypted();
1059    candidate_config
1060        .hydrate_mcp_credentials_from_store(
1061            store
1062                .path()
1063                .parent()
1064                .unwrap_or_else(|| std::path::Path::new(".")),
1065        )
1066        .map_err(|_| ProviderCandidateError::invalid("MCP credential is unavailable"))?;
1067    Ok(McpCandidate {
1068        config: candidate_config,
1069        revision: stored.revision,
1070        source_kind: if stored.recovered_from_backup {
1071            SectionSourceKind::Backup
1072        } else {
1073            SectionSourceKind::File
1074        },
1075        source_path: stored.source_path,
1076        normalized_external_revision: stored.normalized_external_revision,
1077        unchanged,
1078    })
1079}
1080
1081struct McpCandidate {
1082    config: Config,
1083    revision: u64,
1084    source_kind: SectionSourceKind,
1085    source_path: PathBuf,
1086    normalized_external_revision: bool,
1087    unchanged: bool,
1088}
1089
1090/// Project a hydrated runtime section back to its durable comparison shape.
1091/// Public compatibility serialization intentionally retains plaintext beside
1092/// ciphertext, while the sidecar stores only ciphertext for paired secrets.
1093fn mcp_durable_comparison_document(config: &McpConfig) -> McpConfig {
1094    let mut document = config.clone();
1095    for server in &mut document.servers {
1096        match &mut server.transport {
1097            TransportConfig::Stdio(config) => {
1098                config.env.retain(|name, _| {
1099                    !config.env_encrypted.contains_key(name)
1100                        && !config.env_credential_refs.contains_key(name)
1101                });
1102            }
1103            TransportConfig::Sse(config) => clear_paired_header_plaintext(&mut config.headers),
1104            TransportConfig::StreamableHttp(config) => {
1105                clear_paired_header_plaintext(&mut config.headers)
1106            }
1107        }
1108    }
1109    document
1110}
1111
1112fn clear_paired_header_plaintext(headers: &mut [bamboo_mcp::HeaderConfig]) {
1113    for header in headers {
1114        if header.value_encrypted.is_some() || header.credential_ref.is_some() {
1115            header.value.clear();
1116        }
1117    }
1118}
1119
1120fn validate_mcp_config(config: &McpConfig) -> Result<(), String> {
1121    let mut ids = std::collections::HashSet::new();
1122    for server in &config.servers {
1123        if server.id.trim().is_empty() {
1124            return Err("MCP server id cannot be empty".to_string());
1125        }
1126        if !ids.insert(server.id.as_str()) {
1127            return Err(format!("duplicate MCP server id '{}'", server.id));
1128        }
1129        if server.request_timeout_ms == 0 || server.healthcheck_interval_ms == 0 {
1130            return Err(format!(
1131                "MCP server '{}' timeouts must be non-zero",
1132                server.id
1133            ));
1134        }
1135        match &server.transport {
1136            TransportConfig::Stdio(stdio) if stdio.command.trim().is_empty() => {
1137                return Err(format!(
1138                    "MCP stdio server '{}' command cannot be empty",
1139                    server.id
1140                ));
1141            }
1142            TransportConfig::Sse(sse) if sse.url.trim().is_empty() => {
1143                return Err(format!(
1144                    "MCP SSE server '{}' URL cannot be empty",
1145                    server.id
1146                ));
1147            }
1148            TransportConfig::StreamableHttp(http) if http.url.trim().is_empty() => {
1149                return Err(format!(
1150                    "MCP HTTP server '{}' URL cannot be empty",
1151                    server.id
1152                ));
1153            }
1154            _ => {}
1155        }
1156        match &server.transport {
1157            TransportConfig::Stdio(stdio) => {
1158                if !stdio.env_encrypted.is_empty()
1159                    || stdio.env.iter().any(|(name, value)| {
1160                        !value.is_empty() && !stdio.env_credential_refs.contains_key(name)
1161                    })
1162                {
1163                    return Err(format!(
1164                        "MCP server '{}' contains a secret outside the credential store",
1165                        server.id
1166                    ));
1167                }
1168                for raw in stdio.env_credential_refs.values() {
1169                    bamboo_config::CredentialRef::parse(raw.clone())
1170                        .map_err(|_| "MCP credential reference is invalid".to_string())?;
1171                }
1172            }
1173            TransportConfig::Sse(config) => validate_header_refs(&server.id, &config.headers)?,
1174            TransportConfig::StreamableHttp(config) => {
1175                validate_header_refs(&server.id, &config.headers)?
1176            }
1177        }
1178    }
1179    Ok(())
1180}
1181
1182fn validate_provider_config(providers: &ProviderConfigs) -> Result<(), String> {
1183    macro_rules! validate {
1184        ($field:ident) => {
1185            if let Some(provider) = &providers.$field {
1186                if provider.api_key_encrypted.is_some()
1187                    || (!provider.api_key.trim().is_empty()
1188                        && !provider.api_key_from_env
1189                        && provider.credential_ref.is_none())
1190                {
1191                    return Err("provider secret is outside the credential store".to_string());
1192                }
1193            }
1194        };
1195    }
1196    validate!(openai);
1197    validate!(anthropic);
1198    validate!(gemini);
1199    if let Some(provider) = &providers.bodhi {
1200        if provider.api_key_encrypted.is_some()
1201            || (!provider.api_key.trim().is_empty() && provider.credential_ref.is_none())
1202        {
1203            return Err("provider secret is outside the credential store".to_string());
1204        }
1205    }
1206    Ok(())
1207}
1208
1209fn validate_header_refs(
1210    server_id: &str,
1211    headers: &[bamboo_mcp::HeaderConfig],
1212) -> Result<(), String> {
1213    for header in headers {
1214        if header.value_encrypted.is_some()
1215            || (!header.value.is_empty() && header.credential_ref.is_none())
1216        {
1217            return Err(format!(
1218                "MCP server '{server_id}' contains a secret outside the credential store"
1219            ));
1220        }
1221        if let Some(raw) = &header.credential_ref {
1222            bamboo_config::CredentialRef::parse(raw.clone())
1223                .map_err(|_| "MCP credential reference is invalid".to_string())?;
1224        }
1225    }
1226    Ok(())
1227}
1228
1229enum ProviderCandidateErrorKind {
1230    Missing,
1231    InvalidDocument,
1232    Runtime,
1233}
1234
1235struct ProviderCandidateError {
1236    kind: ProviderCandidateErrorKind,
1237    message: String,
1238}
1239
1240impl ProviderCandidateError {
1241    fn missing() -> Self {
1242        Self {
1243            kind: ProviderCandidateErrorKind::Missing,
1244            message: "provider section is missing".to_string(),
1245        }
1246    }
1247
1248    fn invalid(message: &str) -> Self {
1249        Self {
1250            kind: ProviderCandidateErrorKind::InvalidDocument,
1251            message: message.to_string(),
1252        }
1253    }
1254
1255    fn missing_section(message: &str) -> Self {
1256        Self {
1257            kind: ProviderCandidateErrorKind::Missing,
1258            message: message.to_string(),
1259        }
1260    }
1261
1262    fn runtime() -> Self {
1263        Self {
1264            kind: ProviderCandidateErrorKind::Runtime,
1265            message: "provider runtime initialization failed".to_string(),
1266        }
1267    }
1268}
1269
1270fn candidate_error_status(kind: &ProviderCandidateErrorKind) -> SectionStatus {
1271    match kind {
1272        ProviderCandidateErrorKind::Missing => SectionStatus::Missing,
1273        ProviderCandidateErrorKind::InvalidDocument => SectionStatus::Invalid,
1274        ProviderCandidateErrorKind::Runtime => SectionStatus::Degraded,
1275    }
1276}
1277
1278fn update_live_health(
1279    health: &std::sync::RwLock<ConfigLiveHealth>,
1280    status: SectionStatus,
1281    last_error: Option<String>,
1282    advance_revision: bool,
1283    source: Option<(PathBuf, SectionSourceKind)>,
1284) -> u64 {
1285    let mut health = health
1286        .write()
1287        .unwrap_or_else(|poisoned| poisoned.into_inner());
1288    if advance_revision {
1289        health.revision = health.revision.saturating_add(1);
1290    }
1291    health.loaded_at = Utc::now();
1292    health.status = status;
1293    health.last_error = last_error;
1294    if let Some((source_path, source_kind)) = source {
1295        health.source_path = source_path;
1296        health.source_kind = source_kind;
1297    }
1298    health.revision
1299}
1300
1301fn set_live_health_revision(
1302    health: &std::sync::RwLock<ConfigLiveHealth>,
1303    revision: u64,
1304    source: Option<(PathBuf, SectionSourceKind)>,
1305) -> u64 {
1306    let mut health = health
1307        .write()
1308        .unwrap_or_else(|poisoned| poisoned.into_inner());
1309    health.revision = revision;
1310    health.loaded_at = Utc::now();
1311    health.status = SectionStatus::Healthy;
1312    health.last_error = None;
1313    if let Some((source_path, source_kind)) = source {
1314        health.source_path = source_path;
1315        health.source_kind = source_kind;
1316    }
1317    revision
1318}
1319
1320#[derive(Debug)]
1321pub(crate) enum ConfigSectionMutationError {
1322    Store(ConfigStoreError),
1323    Invalid(String),
1324    Runtime(String),
1325}
1326
1327impl AppState {
1328    /// Commit a single ordinary typed section with CAS, then publish exactly
1329    /// that section into the process-owned effective snapshot. Credential
1330    /// bindings are server-owned and cannot be forged or detached here.
1331    pub(crate) async fn put_ordinary_section(
1332        &self,
1333        id: SectionId,
1334        expected_revision: u64,
1335        candidate: Value,
1336    ) -> Result<u64, ConfigSectionMutationError> {
1337        if matches!(
1338            id,
1339            SectionId::Providers | SectionId::Mcp | SectionId::Credentials
1340        ) {
1341            return Err(ConfigSectionMutationError::Invalid(
1342                "this section requires its dedicated endpoint".to_string(),
1343            ));
1344        }
1345        let _io = self.config_io_lock.lock().await;
1346        ensure_provider_mcp_migration_ready(&self.app_data_dir)
1347            .map_err(ConfigSectionMutationError::Store)?;
1348        let facade = self.config_facade.as_ref().ok_or_else(|| {
1349            ConfigSectionMutationError::Invalid(
1350                "typed section writes require the modular configuration facade".to_string(),
1351            )
1352        })?;
1353        let current = facade
1354            .registry()
1355            .envelope_value(id)
1356            .map_err(ConfigSectionMutationError::Store)?;
1357        if credential_reference_inventory(&current.data)
1358            != credential_reference_inventory(&candidate)
1359        {
1360            return Err(ConfigSectionMutationError::Invalid(
1361                "credential references are server-managed; use the credential or domain API"
1362                    .to_string(),
1363            ));
1364        }
1365
1366        let event = facade
1367            .registry()
1368            .commit_value(id, expected_revision, candidate)
1369            .map_err(ConfigSectionMutationError::Store)?;
1370        let revision = match &event {
1371            ConfigSectionEvent::Changed { revision, .. }
1372            | ConfigSectionEvent::Recovered { revision, .. }
1373            | ConfigSectionEvent::Invalid { revision, .. } => *revision,
1374        };
1375        let materialized = materialize_facade_effective_config(facade, &self.app_data_dir);
1376        if materialized.failures.contains(&id) {
1377            let message =
1378                "configuration runtime hydration failed; retaining last-known-good runtime"
1379                    .to_string();
1380            if let Some(invalid) = facade.registry().mark_runtime_degraded(id, message.clone()) {
1381                publish_registry_event(&self.account_sink, &invalid);
1382            }
1383            return Err(ConfigSectionMutationError::Runtime(message));
1384        }
1385
1386        let mut live = self.config.read().await.clone();
1387        let enforcement_newly_off = id == SectionId::ToolsSkills
1388            && !live.plugin_trust.enforcement_is_off()
1389            && materialized.config.plugin_trust.enforcement_is_off();
1390        apply_runtime_section(id, &materialized.config, &mut live);
1391        if id == SectionId::Env {
1392            live.publish_env_vars();
1393        }
1394        *self.config.write().await = live;
1395        if enforcement_newly_off {
1396            warn_plugin_trust_enforcement_off();
1397        }
1398        publish_registry_event(&self.account_sink, &event);
1399        Ok(revision)
1400    }
1401
1402    /// Validate and stage a provider runtime before the first durable CAS
1403    /// write, then publish config/runtime/health/event under `config_io_lock`.
1404    pub(crate) async fn put_provider_section(
1405        &self,
1406        expected_revision: u64,
1407        mut providers: ProviderConfigs,
1408    ) -> Result<u64, ConfigSectionMutationError> {
1409        let _io = self.config_io_lock.lock().await;
1410        ensure_provider_mcp_migration_ready(&self.app_data_dir)
1411            .map_err(ConfigSectionMutationError::Store)?;
1412        let current = self.config.read().await.clone();
1413        retain_provider_credentials(current.providers(), &mut providers);
1414        let mut candidate = current;
1415        *candidate.providers_mut() = providers.clone();
1416        let (candidate, registry, provider) =
1417            match prepare_provider_candidate(candidate, &self.app_data_dir).await {
1418                Ok(prepared) => prepared,
1419                Err(_) => {
1420                    let message =
1421                        "provider runtime initialization failed; retaining last-known-good runtime"
1422                            .to_string();
1423                    publish_section_failure(
1424                        &self.config_live_health,
1425                        &self.account_sink,
1426                        "providers",
1427                        SectionStatus::Degraded,
1428                        message.clone(),
1429                    );
1430                    return Err(ConfigSectionMutationError::Runtime(message));
1431                }
1432            };
1433
1434        let durable_providers = provider_durable_document(&providers)?;
1435        // Acquire every async publication guard before crossing the durable
1436        // boundary. Once commit succeeds, cancellation cannot strand the file
1437        // ahead of the live config/provider snapshots.
1438        let mut live_config = self.config.write().await;
1439        let mut live_provider = self.provider.write().await;
1440        let (revision, source_path) = if let Some(facade) = self.config_facade.as_ref() {
1441            let mut section = facade.registry().providers.snapshot().data.as_ref().clone();
1442            section.providers = durable_providers;
1443            let event = facade
1444                .registry()
1445                .providers
1446                .commit(expected_revision, section)
1447                .map_err(ConfigSectionMutationError::Store)?;
1448            let ConfigSectionEvent::Changed { revision, .. } = event else {
1449                unreachable!("a successful section commit is changed")
1450            };
1451            (
1452                revision,
1453                facade.registry().providers.snapshot().source_path.clone(),
1454            )
1455        } else {
1456            let store = AtomicJsonStore::new(self.app_data_dir.join("providers.json"), 1);
1457            let revision = store
1458                .commit_allowing_unversioned(
1459                    expected_revision,
1460                    durable_providers,
1461                    validate_provider_config,
1462                )
1463                .map_err(ConfigSectionMutationError::Store)?;
1464            (revision, store.path().to_path_buf())
1465        };
1466
1467        candidate.publish_env_vars();
1468        *live_config = candidate;
1469        self.provider_registry.replace_with(registry);
1470        *live_provider = provider;
1471        publish_section_success(
1472            &self.config_live_health,
1473            &self.account_sink,
1474            "providers",
1475            source_path,
1476            section_is_unhealthy(&self.config_live_health),
1477            Some(revision),
1478        );
1479        Ok(revision)
1480    }
1481
1482    /// Replace the complete provider-owned settings domain under the typed
1483    /// provider section revision. Provider metadata and explicitly touched
1484    /// built-in/instance credentials share one recoverable exact transaction;
1485    /// runtime construction is staged before the durable CAS boundary.
1486    pub(crate) async fn put_provider_settings<F>(
1487        &self,
1488        expected_revision: u64,
1489        update: F,
1490    ) -> Result<u64, ConfigSectionMutationError>
1491    where
1492        F: FnOnce(
1493                &Config,
1494                &mut Config,
1495            )
1496                -> Result<(BTreeSet<String>, BTreeSet<String>), ConfigSectionMutationError>
1497            + Send
1498            + 'static,
1499    {
1500        let config_io_lock = self.config_io_lock.clone();
1501        let config = self.config.clone();
1502        let app_data_dir = self.app_data_dir.clone();
1503        let config_facade = self.config_facade.clone();
1504        let account_sink = self.account_sink.clone();
1505        let provider_registry = self.provider_registry.clone();
1506        let provider = self.provider.clone();
1507        let config_live_health = self.config_live_health.clone();
1508        let transaction = tokio::spawn(async move {
1509            let _io = config_io_lock.lock().await;
1510            ensure_provider_mcp_migration_ready(&app_data_dir)
1511                .map_err(ConfigSectionMutationError::Store)?;
1512            let facade = config_facade.as_ref().ok_or_else(|| {
1513                ConfigSectionMutationError::Invalid(
1514                    "provider settings require the modular configuration facade".to_string(),
1515                )
1516            })?;
1517            let current = config.read().await.clone();
1518            let mut candidate = current.clone();
1519            let (provider_intents, provider_instance_intents) = update(&current, &mut candidate)?;
1520
1521            let (candidate, registry, candidate_provider) =
1522                match prepare_provider_candidate(candidate, &app_data_dir).await {
1523                    Ok(prepared) => prepared,
1524                    Err(_) => {
1525                        let message =
1526                        "provider runtime initialization failed; retaining last-known-good runtime"
1527                            .to_string();
1528                        publish_section_failure(
1529                            &config_live_health,
1530                            &account_sink,
1531                            "providers",
1532                            SectionStatus::Degraded,
1533                            message.clone(),
1534                        );
1535                        return Err(ConfigSectionMutationError::Runtime(message));
1536                    }
1537                };
1538
1539            // Acquire publication guards before the durable boundary. The
1540            // detached task owns them until config and provider runtime are
1541            // published, so request cancellation cannot strand disk ahead of
1542            // process state.
1543            let mut live_config = config.write().await;
1544            let mut live_provider = provider.write().await;
1545            let transaction_dir = app_data_dir.clone();
1546            let committed = tokio::task::spawn_blocking(move || {
1547                let mut durable_candidate = candidate;
1548                bamboo_config::persist_provider_credential_transaction_at_revision(
1549                    &transaction_dir,
1550                    &mut durable_candidate,
1551                    &provider_intents,
1552                    &provider_instance_intents,
1553                    expected_revision,
1554                )?;
1555                load_committed_effective_config(&transaction_dir)
1556            })
1557            .await
1558            .map_err(|error| {
1559                ConfigSectionMutationError::Runtime(format!(
1560                    "provider settings transaction task failed: {error}"
1561                ))
1562            })?
1563            .map_err(ConfigSectionMutationError::Store)?;
1564
1565            committed.publish_env_vars();
1566            *live_config = committed;
1567            provider_registry.replace_with(registry);
1568            *live_provider = candidate_provider;
1569            publish_exact_facade_commit(
1570                Some(facade),
1571                &account_sink,
1572                &[SectionId::Credentials, SectionId::Providers],
1573            )
1574            .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
1575            let provider_snapshot = facade.registry().providers.snapshot();
1576            set_live_health_revision(
1577                &config_live_health,
1578                provider_snapshot.revision,
1579                Some((
1580                    provider_snapshot.source_path.clone(),
1581                    SectionSourceKind::File,
1582                )),
1583            );
1584            Ok(provider_snapshot.revision)
1585        });
1586        transaction.await.map_err(|error| {
1587            ConfigSectionMutationError::Runtime(format!(
1588                "provider settings transaction task failed: {error}"
1589            ))
1590        })?
1591    }
1592
1593    /// Reset the complete provider section and all provider-owned credentials
1594    /// in one recoverable exact transaction guarded by the typed section
1595    /// revision. Runtime publication remains last-known-good when the default
1596    /// provider cannot be initialized without credentials.
1597    pub(crate) async fn reset_provider_section(
1598        &self,
1599        expected_revision: u64,
1600    ) -> Result<u64, ConfigSectionMutationError> {
1601        let config_io_lock = self.config_io_lock.clone();
1602        let config = self.config.clone();
1603        let app_data_dir = self.app_data_dir.clone();
1604        let config_facade = self.config_facade.clone();
1605        let account_sink = self.account_sink.clone();
1606        let provider_registry = self.provider_registry.clone();
1607        let provider = self.provider.clone();
1608        let config_live_health = self.config_live_health.clone();
1609        let transaction = tokio::spawn(async move {
1610            let _io = config_io_lock.lock().await;
1611            ensure_provider_mcp_migration_ready(&app_data_dir)
1612                .map_err(ConfigSectionMutationError::Store)?;
1613            let facade = config_facade.as_ref().ok_or_else(|| {
1614                ConfigSectionMutationError::Invalid(
1615                    "provider reset requires the modular configuration facade".to_string(),
1616                )
1617            })?;
1618            let current = config.read().await.clone();
1619            let provider_intents = BTreeSet::from([
1620                "openai".to_string(),
1621                "anthropic".to_string(),
1622                "gemini".to_string(),
1623                "bodhi".to_string(),
1624            ]);
1625            let provider_instance_intents = current.provider_instances.keys().cloned().collect();
1626            let mut candidate = current;
1627            apply_runtime_section(SectionId::Providers, &Config::default(), &mut candidate);
1628
1629            let transaction_dir = app_data_dir.clone();
1630            let candidate = tokio::task::spawn_blocking(move || {
1631                bamboo_config::persist_provider_reset_credential_transaction_at_revision(
1632                    &transaction_dir,
1633                    &mut candidate,
1634                    &provider_intents,
1635                    &provider_instance_intents,
1636                    expected_revision,
1637                )?;
1638                load_committed_effective_config(&transaction_dir)
1639            })
1640            .await
1641            .map_err(|error| {
1642                ConfigSectionMutationError::Runtime(format!(
1643                    "provider reset transaction task failed: {error}"
1644                ))
1645            })?
1646            .map_err(ConfigSectionMutationError::Store)?;
1647
1648            *config.write().await = candidate.clone();
1649            publish_exact_facade_commit(
1650                Some(facade),
1651                &account_sink,
1652                &[SectionId::Credentials, SectionId::Providers],
1653            )
1654            .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
1655            let provider_snapshot = facade.registry().providers.snapshot();
1656            set_live_health_revision(
1657                &config_live_health,
1658                provider_snapshot.revision,
1659                Some((
1660                    provider_snapshot.source_path.clone(),
1661                    SectionSourceKind::File,
1662                )),
1663            );
1664
1665            match bamboo_llm::ProviderRegistry::from_config(&candidate, app_data_dir).await {
1666                Ok(registry) => {
1667                    if let Some(candidate_provider) = registry.get_default() {
1668                        provider_registry.replace_with(registry);
1669                        *provider.write().await = candidate_provider;
1670                    } else {
1671                        publish_section_failure(
1672                            &config_live_health,
1673                            &account_sink,
1674                            "providers",
1675                            SectionStatus::Degraded,
1676                            "provider reset committed; default provider is not initialized"
1677                                .to_string(),
1678                        );
1679                    }
1680                }
1681                Err(error) => {
1682                    tracing::warn!(error = %error, "provider reset committed but runtime initialization failed");
1683                    publish_section_failure(
1684                        &config_live_health,
1685                        &account_sink,
1686                        "providers",
1687                        SectionStatus::Degraded,
1688                        "provider reset committed; retaining last-known-good runtime".to_string(),
1689                    );
1690                }
1691            }
1692            Ok(provider_snapshot.revision)
1693        });
1694        transaction.await.map_err(|error| {
1695            ConfigSectionMutationError::Runtime(format!(
1696                "provider reset transaction task failed: {error}"
1697            ))
1698        })?
1699    }
1700
1701    /// Stage MCP connection/initialization/tool discovery, perform the CAS at
1702    /// the manager's pre-publication boundary, then publish the config snapshot
1703    /// before the prepared runtimes and finally emit one section event.
1704    pub(crate) async fn put_mcp_section(
1705        &self,
1706        expected_revision: u64,
1707        mut candidate: McpConfig,
1708    ) -> Result<u64, ConfigSectionMutationError> {
1709        let _io = self.config_io_lock.lock().await;
1710        ensure_provider_mcp_migration_ready(&self.app_data_dir)
1711            .map_err(ConfigSectionMutationError::Store)?;
1712        retain_mcp_credentials(
1713            &self.config.read().await.mcp,
1714            &mut candidate,
1715            &BTreeSet::new(),
1716        );
1717        validate_mcp_config(&candidate).map_err(ConfigSectionMutationError::Invalid)?;
1718        let mut hydration_config = Config::default();
1719        hydration_config.mcp = candidate;
1720        hydration_config
1721            .hydrate_mcp_credentials_from_store(&self.app_data_dir)
1722            .map_err(|_| {
1723                ConfigSectionMutationError::Invalid(
1724                    "referenced MCP credential is unavailable".to_string(),
1725                )
1726            })?;
1727        let candidate = hydration_config.mcp.clone();
1728        let mut revision = None;
1729        let mut store_error = None;
1730        let durable_candidate = credential_ref_mcp_document(&candidate)?;
1731        let mut next_config = candidate.clone();
1732        retain_mcp_credential_refs(&durable_candidate, &mut next_config);
1733        let result = self
1734            .mcp_manager
1735            .reconcile_from_config_transactional_after(&candidate, || async {
1736                // Stage may await freely, but acquire the snapshot guard before
1737                // the durable boundary. Commit + snapshot publication below is
1738                // then one cancellation-free synchronous critical section.
1739                let mut live_config = self.config.write().await;
1740                let commit = if let Some(facade) = self.config_facade.as_ref() {
1741                    facade
1742                        .registry()
1743                        .mcp
1744                        .commit(expected_revision, McpSection(durable_candidate))
1745                        .map(|event| match event {
1746                            ConfigSectionEvent::Changed { revision, .. } => revision,
1747                            _ => unreachable!("a successful section commit is changed"),
1748                        })
1749                } else {
1750                    AtomicJsonStore::new(self.app_data_dir.join("mcp.json"), 1).commit(
1751                        expected_revision,
1752                        durable_candidate,
1753                        validate_mcp_config,
1754                    )
1755                };
1756                match commit {
1757                    Ok(committed) => {
1758                        live_config.mcp = next_config;
1759                        revision = Some(committed);
1760                        Ok(())
1761                    }
1762                    Err(error) => {
1763                        store_error = Some(error);
1764                        Err(bamboo_mcp::McpError::InvalidConfig(
1765                            "MCP section durable commit failed".to_string(),
1766                        ))
1767                    }
1768                }
1769            })
1770            .await;
1771        if let Some(error) = store_error {
1772            return Err(ConfigSectionMutationError::Store(error));
1773        }
1774        if result.is_err() {
1775            let message =
1776                "MCP runtime initialization failed; retaining last-known-good runtime".to_string();
1777            publish_section_failure(
1778                &self.mcp_config_live_health,
1779                &self.account_sink,
1780                "mcp",
1781                SectionStatus::Degraded,
1782                message.clone(),
1783            );
1784            return Err(ConfigSectionMutationError::Runtime(message));
1785        }
1786        let revision = revision.expect("successful MCP reconcile commits a revision");
1787        publish_section_success(
1788            &self.mcp_config_live_health,
1789            &self.account_sink,
1790            "mcp",
1791            self.app_data_dir.join("mcp.json"),
1792            section_is_unhealthy(&self.mcp_config_live_health),
1793            Some(revision),
1794        );
1795        Ok(revision)
1796    }
1797
1798    /// Replace editable MCP metadata and explicitly touched credentials under
1799    /// one section CAS. Runtime construction is staged first; credential and
1800    /// MCP documents cross the durable boundary together.
1801    pub(crate) async fn put_mcp_settings(
1802        &self,
1803        expected_revision: u64,
1804        candidate: McpConfig,
1805        credential_intents: BTreeSet<bamboo_config::CredentialRef>,
1806    ) -> Result<u64, ConfigSectionMutationError> {
1807        if credential_intents.is_empty() {
1808            return self.put_mcp_section(expected_revision, candidate).await;
1809        }
1810
1811        let config_io_lock = self.config_io_lock.clone();
1812        let config = self.config.clone();
1813        let app_data_dir = self.app_data_dir.clone();
1814        let config_facade = self.config_facade.clone();
1815        let account_sink = self.account_sink.clone();
1816        let mcp_manager = self.mcp_manager.clone();
1817        let mcp_config_live_health = self.mcp_config_live_health.clone();
1818        let transaction = tokio::spawn(async move {
1819            let _io = config_io_lock.lock().await;
1820            ensure_provider_mcp_migration_ready(&app_data_dir)
1821                .map_err(ConfigSectionMutationError::Store)?;
1822            let facade = config_facade.as_ref().ok_or_else(|| {
1823                ConfigSectionMutationError::Invalid(
1824                    "MCP settings require the modular configuration facade".to_string(),
1825                )
1826            })?;
1827            let current = config.read().await.clone();
1828            let mut runtime_candidate = candidate;
1829            materialize_mcp_touched_replacements(&mut runtime_candidate, &credential_intents)
1830                .map_err(ConfigSectionMutationError::Invalid)?;
1831            retain_mcp_credentials(&current.mcp, &mut runtime_candidate, &credential_intents);
1832            validate_mcp_config(&runtime_candidate).map_err(ConfigSectionMutationError::Invalid)?;
1833
1834            let mut transaction_error = None;
1835            let transaction_dir = app_data_dir.clone();
1836            let mut durable_candidate = current;
1837            durable_candidate.mcp = runtime_candidate.clone();
1838            let result = mcp_manager
1839                .reconcile_from_config_transactional_after(&runtime_candidate, || async {
1840                    let mut live_config = config.write().await;
1841                    let commit = tokio::task::spawn_blocking(move || {
1842                        bamboo_config::persist_mcp_credential_transaction_at_revision(
1843                            &transaction_dir,
1844                            &mut durable_candidate,
1845                            &credential_intents,
1846                            expected_revision,
1847                        )?;
1848                        load_committed_effective_config(&transaction_dir)
1849                    })
1850                    .await;
1851                    match commit {
1852                        Ok(Ok(committed)) => {
1853                            *live_config = committed;
1854                            Ok(())
1855                        }
1856                        Ok(Err(error)) => {
1857                            transaction_error = Some(ConfigSectionMutationError::Store(error));
1858                            Err(bamboo_mcp::McpError::InvalidConfig(
1859                                "MCP settings durable transaction failed".to_string(),
1860                            ))
1861                        }
1862                        Err(error) => {
1863                            transaction_error = Some(ConfigSectionMutationError::Runtime(format!(
1864                                "MCP settings transaction task failed: {error}"
1865                            )));
1866                            Err(bamboo_mcp::McpError::InvalidConfig(
1867                                "MCP settings durable transaction failed".to_string(),
1868                            ))
1869                        }
1870                    }
1871                })
1872                .await;
1873            if let Some(error) = transaction_error {
1874                return Err(error);
1875            }
1876            if result.is_err() {
1877                let message =
1878                    "MCP runtime initialization failed; retaining last-known-good runtime"
1879                        .to_string();
1880                publish_section_failure(
1881                    &mcp_config_live_health,
1882                    &account_sink,
1883                    "mcp",
1884                    SectionStatus::Degraded,
1885                    message.clone(),
1886                );
1887                return Err(ConfigSectionMutationError::Runtime(message));
1888            }
1889
1890            publish_exact_facade_commit(
1891                Some(facade),
1892                &account_sink,
1893                &[SectionId::Credentials, SectionId::Mcp],
1894            )
1895            .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
1896            let snapshot = facade.registry().mcp.snapshot();
1897            set_live_health_revision(
1898                &mcp_config_live_health,
1899                snapshot.revision,
1900                Some((snapshot.source_path.clone(), SectionSourceKind::File)),
1901            );
1902            Ok(snapshot.revision)
1903        });
1904        transaction.await.map_err(|error| {
1905            ConfigSectionMutationError::Runtime(format!(
1906                "MCP settings transaction task failed: {error}"
1907            ))
1908        })?
1909    }
1910
1911    /// Reset MCP metadata and its owned credentials at the runtime manager's
1912    /// pre-publication boundary, preserving the same durable-before-live
1913    /// ordering as a normal typed MCP write.
1914    pub(crate) async fn reset_mcp_section(
1915        &self,
1916        expected_revision: u64,
1917    ) -> Result<u64, ConfigSectionMutationError> {
1918        let _io = self.config_io_lock.lock().await;
1919        ensure_provider_mcp_migration_ready(&self.app_data_dir)
1920            .map_err(ConfigSectionMutationError::Store)?;
1921        let facade = self.config_facade.as_ref().ok_or_else(|| {
1922            ConfigSectionMutationError::Invalid(
1923                "MCP reset requires the modular configuration facade".to_string(),
1924            )
1925        })?;
1926        let candidate_mcp = McpConfig::default();
1927        let mut candidate_config = self.config.read().await.clone();
1928        candidate_config.mcp = candidate_mcp.clone();
1929        let mut committed_config = None;
1930        let mut store_error = None;
1931        let data_dir = self.app_data_dir.clone();
1932        let result = self
1933            .mcp_manager
1934            .reconcile_from_config_transactional_after(&candidate_mcp, || async {
1935                let mut live_config = self.config.write().await;
1936                match bamboo_config::persist_mcp_reset_credential_transaction_at_revision(
1937                    &data_dir,
1938                    &mut candidate_config,
1939                    expected_revision,
1940                )
1941                .and_then(|_| load_committed_effective_config(&data_dir))
1942                {
1943                    Ok(committed) => {
1944                        *live_config = committed.clone();
1945                        committed_config = Some(committed);
1946                        Ok(())
1947                    }
1948                    Err(error) => {
1949                        store_error = Some(error);
1950                        Err(bamboo_mcp::McpError::InvalidConfig(
1951                            "MCP reset durable commit failed".to_string(),
1952                        ))
1953                    }
1954                }
1955            })
1956            .await;
1957        if let Some(error) = store_error {
1958            return Err(ConfigSectionMutationError::Store(error));
1959        }
1960        if result.is_err() || committed_config.is_none() {
1961            let message =
1962                "MCP reset runtime initialization failed; retaining last-known-good runtime"
1963                    .to_string();
1964            publish_section_failure(
1965                &self.mcp_config_live_health,
1966                &self.account_sink,
1967                "mcp",
1968                SectionStatus::Degraded,
1969                message.clone(),
1970            );
1971            return Err(ConfigSectionMutationError::Runtime(message));
1972        }
1973        publish_exact_facade_commit(
1974            Some(facade),
1975            &self.account_sink,
1976            &[SectionId::Credentials, SectionId::Mcp],
1977        )
1978        .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
1979        let revision = facade.registry().mcp.snapshot().revision;
1980        publish_section_success(
1981            &self.mcp_config_live_health,
1982            &self.account_sink,
1983            "mcp",
1984            self.app_data_dir.join("mcp.json"),
1985            section_is_unhealthy(&self.mcp_config_live_health),
1986            Some(revision),
1987        );
1988        Ok(revision)
1989    }
1990
1991    /// Reset a credential-backed non-provider section using the typed section
1992    /// revision as the client CAS authority. The exact transaction rebases on
1993    /// the latest unrelated credential document while clearing only references
1994    /// owned by this section.
1995    pub(crate) async fn reset_credential_backed_section(
1996        &self,
1997        id: SectionId,
1998        expected_revision: u64,
1999    ) -> Result<u64, ConfigSectionMutationError> {
2000        if !matches!(
2001            id,
2002            SectionId::Core
2003                | SectionId::Notifications
2004                | SectionId::Connect
2005                | SectionId::Env
2006                | SectionId::ClusterFabric
2007                | SectionId::AccessControl
2008        ) {
2009            return Err(ConfigSectionMutationError::Invalid(
2010                "section is not a credential-backed reset domain".to_string(),
2011            ));
2012        }
2013        let config_io_lock = self.config_io_lock.clone();
2014        let config = self.config.clone();
2015        let app_data_dir = self.app_data_dir.clone();
2016        let config_facade = self.config_facade.clone();
2017        let account_sink = self.account_sink.clone();
2018        let provider_registry = self.provider_registry.clone();
2019        let provider = self.provider.clone();
2020        let mcp_manager = self.mcp_manager.clone();
2021        let transaction = tokio::spawn(async move {
2022            let _io = config_io_lock.lock().await;
2023            ensure_provider_mcp_migration_ready(&app_data_dir)
2024                .map_err(ConfigSectionMutationError::Store)?;
2025            let facade = config_facade.as_ref().ok_or_else(|| {
2026                ConfigSectionMutationError::Invalid(
2027                    "section reset requires the modular configuration facade".to_string(),
2028                )
2029            })?;
2030            let mut candidate = config.read().await.clone();
2031            apply_runtime_section(id, &Config::default(), &mut candidate);
2032            let transaction_dir = app_data_dir.clone();
2033            let candidate = tokio::task::spawn_blocking(move || {
2034                bamboo_config::persist_credential_backed_section_reset_at_revision(
2035                    &transaction_dir,
2036                    &mut candidate,
2037                    id,
2038                    expected_revision,
2039                )?;
2040                load_committed_effective_config(&transaction_dir)
2041            })
2042            .await
2043            .map_err(|error| {
2044                ConfigSectionMutationError::Runtime(format!(
2045                    "section reset transaction task failed: {error}"
2046                ))
2047            })?
2048            .map_err(ConfigSectionMutationError::Store)?;
2049
2050            if id == SectionId::Env {
2051                candidate.publish_env_vars();
2052            }
2053            *config.write().await = candidate.clone();
2054            publish_exact_facade_commit(Some(facade), &account_sink, &[SectionId::Credentials, id])
2055                .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
2056
2057            if id == SectionId::Core {
2058                match bamboo_llm::ProviderRegistry::from_config(&candidate, app_data_dir.clone())
2059                    .await
2060                {
2061                    Ok(registry) => {
2062                        if let Some(candidate_provider) = registry.get_default() {
2063                            provider_registry.replace_with(registry);
2064                            *provider.write().await = candidate_provider;
2065                        }
2066                    }
2067                    Err(error) => {
2068                        tracing::warn!(error = %error, "core reset committed but provider reload failed");
2069                    }
2070                }
2071                mcp_manager.reconcile_from_config(&candidate.mcp).await;
2072            }
2073
2074            facade
2075                .registry()
2076                .envelope_value(id)
2077                .map(|envelope| envelope.revision)
2078                .map_err(ConfigSectionMutationError::Store)
2079        });
2080        transaction.await.map_err(|error| {
2081            ConfigSectionMutationError::Runtime(format!(
2082                "section reset transaction task failed: {error}"
2083            ))
2084        })?
2085    }
2086}
2087
2088fn credential_reference_inventory(value: &Value) -> std::collections::BTreeMap<String, Value> {
2089    fn collect(value: &Value, path: &str, output: &mut std::collections::BTreeMap<String, Value>) {
2090        match value {
2091            Value::Object(object) => {
2092                for (key, value) in object {
2093                    let child_path =
2094                        format!("{path}/{}", key.replace('~', "~0").replace('/', "~1"));
2095                    let normalized = key
2096                        .chars()
2097                        .filter(|ch| ch.is_ascii_alphanumeric())
2098                        .flat_map(char::to_lowercase)
2099                        .collect::<String>();
2100                    if normalized == "credentialref"
2101                        || normalized.ends_with("credentialref")
2102                        || normalized.ends_with("credentialrefs")
2103                    {
2104                        output.insert(child_path, value.clone());
2105                    } else {
2106                        collect(value, &child_path, output);
2107                    }
2108                }
2109            }
2110            Value::Array(values) => {
2111                for (index, value) in values.iter().enumerate() {
2112                    collect(value, &format!("{path}/{index}"), output);
2113                }
2114            }
2115            _ => {}
2116        }
2117    }
2118
2119    let mut output = std::collections::BTreeMap::new();
2120    collect(value, "", &mut output);
2121    output
2122}
2123
2124fn provider_durable_document(
2125    providers: &ProviderConfigs,
2126) -> Result<ProviderConfigs, ConfigSectionMutationError> {
2127    let mut document = providers.clone();
2128    macro_rules! sanitize {
2129        ($field:ident) => {
2130            if let Some(provider) = document.$field.as_mut() {
2131                provider.api_key.clear();
2132                provider.api_key_encrypted = None;
2133            }
2134        };
2135    }
2136    sanitize!(openai);
2137    sanitize!(anthropic);
2138    sanitize!(gemini);
2139    if let Some(provider) = document.bodhi.as_mut() {
2140        provider.api_key.clear();
2141        provider.api_key_encrypted = None;
2142    }
2143    validate_provider_config(&document).map_err(ConfigSectionMutationError::Invalid)?;
2144    Ok(document)
2145}
2146
2147fn retain_provider_credentials(current: &ProviderConfigs, candidate: &mut ProviderConfigs) {
2148    candidate.extra = current.extra.clone();
2149    macro_rules! retain {
2150        ($field:ident) => {
2151            if let (Some(current), Some(candidate)) = (&current.$field, &mut candidate.$field) {
2152                candidate.api_key = current.api_key.clone();
2153                candidate.api_key_encrypted = current.api_key_encrypted.clone();
2154                if candidate.credential_ref.is_none() {
2155                    candidate.credential_ref = current.credential_ref.clone();
2156                }
2157                if candidate.credential_ref != current.credential_ref {
2158                    candidate.api_key.clear();
2159                    candidate.api_key_encrypted = None;
2160                }
2161                candidate.api_key_from_env = current.api_key_from_env;
2162                candidate.request_overrides = current.request_overrides.clone();
2163                candidate.extra = current.extra.clone();
2164            }
2165        };
2166    }
2167    retain!(openai);
2168    retain!(anthropic);
2169    retain!(gemini);
2170    if let (Some(current), Some(candidate)) = (&current.bodhi, &mut candidate.bodhi) {
2171        candidate.api_key = current.api_key.clone();
2172        candidate.api_key_encrypted = current.api_key_encrypted.clone();
2173        if candidate.credential_ref.is_none() {
2174            candidate.credential_ref = current.credential_ref.clone();
2175        }
2176        if candidate.credential_ref != current.credential_ref {
2177            candidate.api_key.clear();
2178            candidate.api_key_encrypted = None;
2179        }
2180        candidate.extra = current.extra.clone();
2181    }
2182    if let (Some(current), Some(candidate)) = (&current.copilot, &mut candidate.copilot) {
2183        candidate.request_overrides = current.request_overrides.clone();
2184        candidate.extra = current.extra.clone();
2185    }
2186}
2187
2188fn retain_mcp_credentials(
2189    current: &McpConfig,
2190    candidate: &mut McpConfig,
2191    touched: &BTreeSet<bamboo_config::CredentialRef>,
2192) {
2193    for candidate_server in &mut candidate.servers {
2194        let Some(current_server) = current
2195            .servers
2196            .iter()
2197            .find(|server| server.id == candidate_server.id)
2198        else {
2199            continue;
2200        };
2201        if let (TransportConfig::Stdio(current), TransportConfig::Stdio(candidate)) =
2202            (&current_server.transport, &mut candidate_server.transport)
2203        {
2204            if candidate.env.is_empty()
2205                && candidate.env_encrypted.is_empty()
2206                && candidate.env_credential_refs.is_empty()
2207            {
2208                for (name, reference) in &current.env_credential_refs {
2209                    if mcp_credential_ref_is_touched(Some(reference), touched) {
2210                        continue;
2211                    }
2212                    candidate
2213                        .env_credential_refs
2214                        .insert(name.clone(), reference.clone());
2215                    if let Some(value) = current.env.get(name) {
2216                        candidate.env.insert(name.clone(), value.clone());
2217                    }
2218                    if let Some(value) = current.env_encrypted.get(name) {
2219                        candidate.env_encrypted.insert(name.clone(), value.clone());
2220                    }
2221                }
2222            } else {
2223                for (name, reference) in &current.env_credential_refs {
2224                    if candidate.env_credential_refs.get(name) != Some(reference) {
2225                        continue;
2226                    }
2227                    if candidate.env.get(name).is_none_or(|value| value.is_empty()) {
2228                        if let Some(value) = current.env.get(name) {
2229                            candidate.env.insert(name.clone(), value.clone());
2230                        }
2231                    }
2232                }
2233            }
2234        }
2235        match (&current_server.transport, &mut candidate_server.transport) {
2236            (TransportConfig::Sse(current), TransportConfig::Sse(candidate)) => {
2237                retain_mcp_header_credentials(&current.headers, &mut candidate.headers)
2238            }
2239            (
2240                TransportConfig::StreamableHttp(current),
2241                TransportConfig::StreamableHttp(candidate),
2242            ) => retain_mcp_header_credentials(&current.headers, &mut candidate.headers),
2243            _ => {}
2244        }
2245    }
2246}
2247
2248fn materialize_mcp_touched_replacements(
2249    candidate: &mut McpConfig,
2250    touched: &BTreeSet<bamboo_config::CredentialRef>,
2251) -> Result<(), String> {
2252    let mut replacements = BTreeMap::<bamboo_config::CredentialRef, String>::new();
2253    for server in &candidate.servers {
2254        match &server.transport {
2255            TransportConfig::Stdio(stdio) => {
2256                for (name, raw_reference) in &stdio.env_credential_refs {
2257                    let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2258                        .map_err(|_| "MCP credential reference is invalid".to_string())?;
2259                    if let Some(value) = stdio
2260                        .env
2261                        .get(name)
2262                        .filter(|value| touched.contains(&reference) && !value.is_empty())
2263                    {
2264                        insert_mcp_replacement(&mut replacements, reference, value)?;
2265                    }
2266                }
2267            }
2268            TransportConfig::Sse(http) => {
2269                collect_mcp_header_replacements(&http.headers, touched, &mut replacements)?
2270            }
2271            TransportConfig::StreamableHttp(http) => {
2272                collect_mcp_header_replacements(&http.headers, touched, &mut replacements)?
2273            }
2274        }
2275    }
2276    if replacements.is_empty() {
2277        return Ok(());
2278    }
2279    for server in &mut candidate.servers {
2280        match &mut server.transport {
2281            TransportConfig::Stdio(stdio) => {
2282                for (name, raw_reference) in &stdio.env_credential_refs {
2283                    let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2284                        .map_err(|_| "MCP credential reference is invalid".to_string())?;
2285                    if let Some(value) = replacements.get(&reference) {
2286                        stdio.env.insert(name.clone(), value.clone());
2287                    }
2288                }
2289            }
2290            TransportConfig::Sse(http) => {
2291                apply_mcp_header_replacements(&mut http.headers, &replacements)?
2292            }
2293            TransportConfig::StreamableHttp(http) => {
2294                apply_mcp_header_replacements(&mut http.headers, &replacements)?
2295            }
2296        }
2297    }
2298    Ok(())
2299}
2300
2301fn collect_mcp_header_replacements(
2302    headers: &[bamboo_mcp::HeaderConfig],
2303    touched: &BTreeSet<bamboo_config::CredentialRef>,
2304    replacements: &mut BTreeMap<bamboo_config::CredentialRef, String>,
2305) -> Result<(), String> {
2306    for header in headers {
2307        let Some(raw_reference) = header.credential_ref.as_ref() else {
2308            continue;
2309        };
2310        let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2311            .map_err(|_| "MCP credential reference is invalid".to_string())?;
2312        if touched.contains(&reference) && !header.value.is_empty() {
2313            insert_mcp_replacement(replacements, reference, &header.value)?;
2314        }
2315    }
2316    Ok(())
2317}
2318
2319fn insert_mcp_replacement(
2320    replacements: &mut BTreeMap<bamboo_config::CredentialRef, String>,
2321    reference: bamboo_config::CredentialRef,
2322    value: &str,
2323) -> Result<(), String> {
2324    match replacements.get(&reference) {
2325        Some(existing) if existing != value => {
2326            Err("MCP updates assign conflicting values to one credential reference".to_string())
2327        }
2328        Some(_) => Ok(()),
2329        None => {
2330            replacements.insert(reference, value.to_string());
2331            Ok(())
2332        }
2333    }
2334}
2335
2336fn apply_mcp_header_replacements(
2337    headers: &mut [bamboo_mcp::HeaderConfig],
2338    replacements: &BTreeMap<bamboo_config::CredentialRef, String>,
2339) -> Result<(), String> {
2340    for header in headers {
2341        let Some(raw_reference) = header.credential_ref.as_ref() else {
2342            continue;
2343        };
2344        let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2345            .map_err(|_| "MCP credential reference is invalid".to_string())?;
2346        if let Some(value) = replacements.get(&reference) {
2347            header.value = value.clone();
2348        }
2349    }
2350    Ok(())
2351}
2352
2353fn mcp_credential_ref_is_touched(
2354    raw_reference: Option<&String>,
2355    touched: &BTreeSet<bamboo_config::CredentialRef>,
2356) -> bool {
2357    raw_reference
2358        .and_then(|raw| bamboo_config::CredentialRef::parse(raw.clone()).ok())
2359        .is_some_and(|reference| touched.contains(&reference))
2360}
2361
2362fn retain_mcp_header_credentials(
2363    current: &[bamboo_mcp::HeaderConfig],
2364    candidate: &mut [bamboo_mcp::HeaderConfig],
2365) {
2366    for candidate_header in candidate {
2367        let Some(current_header) = current
2368            .iter()
2369            .find(|header| header.name == candidate_header.name)
2370        else {
2371            continue;
2372        };
2373        if candidate_header.credential_ref == current_header.credential_ref
2374            && candidate_header.value.is_empty()
2375        {
2376            candidate_header.value = current_header.value.clone();
2377            candidate_header.value_encrypted = current_header.value_encrypted.clone();
2378        }
2379    }
2380}
2381
2382fn credential_ref_mcp_document(
2383    runtime: &McpConfig,
2384) -> Result<McpConfig, ConfigSectionMutationError> {
2385    let mut document = runtime.clone();
2386    for server in &mut document.servers {
2387        match &mut server.transport {
2388            TransportConfig::Stdio(config) => {
2389                config.env_encrypted.clear();
2390                config.env.retain(|name, value| {
2391                    !(value.is_empty() || config.env_credential_refs.contains_key(name))
2392                });
2393                if !config.env.is_empty() {
2394                    return Err(ConfigSectionMutationError::Invalid(
2395                        "MCP secret requires a credential reference".to_string(),
2396                    ));
2397                }
2398            }
2399            TransportConfig::Sse(config) => reference_headers(&mut config.headers)?,
2400            TransportConfig::StreamableHttp(config) => reference_headers(&mut config.headers)?,
2401        }
2402    }
2403    Ok(document)
2404}
2405
2406fn retain_mcp_credential_refs(document: &McpConfig, runtime: &mut McpConfig) {
2407    for runtime_server in &mut runtime.servers {
2408        let Some(document_server) = document
2409            .servers
2410            .iter()
2411            .find(|server| server.id == runtime_server.id)
2412        else {
2413            continue;
2414        };
2415        match (&document_server.transport, &mut runtime_server.transport) {
2416            (TransportConfig::Stdio(document), TransportConfig::Stdio(runtime)) => {
2417                runtime.env_encrypted.clear();
2418                runtime.env_credential_refs = document.env_credential_refs.clone();
2419            }
2420            (TransportConfig::Sse(document), TransportConfig::Sse(runtime)) => {
2421                copy_header_ciphertext(&document.headers, &mut runtime.headers);
2422            }
2423            (
2424                TransportConfig::StreamableHttp(document),
2425                TransportConfig::StreamableHttp(runtime),
2426            ) => copy_header_ciphertext(&document.headers, &mut runtime.headers),
2427            _ => {}
2428        }
2429    }
2430}
2431
2432fn copy_header_ciphertext(
2433    document: &[bamboo_mcp::HeaderConfig],
2434    runtime: &mut [bamboo_mcp::HeaderConfig],
2435) {
2436    for runtime_header in runtime {
2437        if let Some(document_header) = document
2438            .iter()
2439            .find(|header| header.name == runtime_header.name)
2440        {
2441            runtime_header.value_encrypted = None;
2442            runtime_header.credential_ref = document_header.credential_ref.clone();
2443        }
2444    }
2445}
2446
2447fn reference_headers(
2448    headers: &mut [bamboo_mcp::HeaderConfig],
2449) -> Result<(), ConfigSectionMutationError> {
2450    for header in headers {
2451        if !header.value.is_empty() && header.credential_ref.is_none() {
2452            return Err(ConfigSectionMutationError::Invalid(
2453                "MCP secret requires a credential reference".to_string(),
2454            ));
2455        }
2456        header.value.clear();
2457        header.value_encrypted = None;
2458    }
2459    Ok(())
2460}
2461
2462impl AppState {
2463    /// Reload the provider based on current configuration
2464    ///
2465    /// Re-reads the configuration and creates a new LLM provider
2466    /// instance, allowing runtime switching of providers or models.
2467    ///
2468    /// # Returns
2469    ///
2470    /// `Ok(())` if the provider was successfully reloaded.
2471    ///
2472    /// # Errors
2473    ///
2474    /// Returns an error if:
2475    /// - Configuration cannot be read
2476    /// - Provider initialization fails (e.g., invalid API key)
2477    ///
2478    /// # Example
2479    ///
2480    /// ```rust,no_run
2481    /// use bamboo_server::app_state::AppState;
2482    /// use std::path::PathBuf;
2483    ///
2484    /// #[tokio::main]
2485    /// async fn main() {
2486    ///     let state = AppState::new(PathBuf::from("/path/to/.bamboo"))
2487    ///         .await
2488    ///         .expect("failed to initialize app state");
2489    ///
2490    ///     // User updated config file...
2491    ///     state.reload_provider().await.expect("Provider reload failed");
2492    /// }
2493    /// ```
2494    pub async fn reload_provider(&self) -> Result<(), bamboo_llm::LLMError> {
2495        let config = self.config.read().await.clone();
2496        let candidate_registry =
2497            bamboo_llm::ProviderRegistry::from_config(&config, self.app_data_dir.clone()).await?;
2498        let default_provider_name = candidate_registry.default_provider_name();
2499        tracing::info!(
2500            default_provider = %default_provider_name,
2501            legacy_provider = %config.provider,
2502            has_provider_instances = config.has_provider_instances(),
2503            "Reloading provider runtime from current config"
2504        );
2505
2506        let new_provider = candidate_registry.get_default().ok_or_else(|| {
2507            let message = if config.has_provider_instances() {
2508                format!(
2509                    "Default provider instance '{}' is not available or failed to initialize",
2510                    default_provider_name
2511                )
2512            } else {
2513                format!(
2514                    "Provider '{}' is not available or failed to initialize",
2515                    config.provider
2516                )
2517            };
2518            bamboo_llm::LLMError::Auth(message)
2519        })?;
2520
2521        let mut provider = self.provider.write().await;
2522        self.provider_registry.replace_with(candidate_registry);
2523        *provider = new_provider;
2524
2525        tracing::info!(
2526            default_provider = %default_provider_name,
2527            "Provider reloaded successfully"
2528        );
2529        Ok(())
2530    }
2531
2532    /// Reload the configuration from file
2533    ///
2534    /// Reads the configuration file again and updates the in-memory
2535    /// config. Note: This does NOT automatically reload the provider;
2536    /// call `reload_provider()` afterwards if needed.
2537    ///
2538    /// # Returns
2539    ///
2540    /// The newly loaded configuration.
2541    ///
2542    /// # Example
2543    ///
2544    /// ```rust,no_run
2545    /// use bamboo_server::app_state::AppState;
2546    /// use std::path::PathBuf;
2547    ///
2548    /// #[tokio::main]
2549    /// async fn main() {
2550    ///     let state = AppState::new(PathBuf::from("/path/to/.bamboo"))
2551    ///         .await
2552    ///         .expect("failed to initialize app state");
2553    ///
2554    ///     // Reload config from disk
2555    ///     let new_config = state.reload_config().await;
2556    ///
2557    ///     // Optionally reload provider with new config
2558    ///     state.reload_provider().await.ok();
2559    /// }
2560    /// ```
2561    pub async fn reload_config(&self) -> Config {
2562        // The process facade is the only production read authority. Its
2563        // watcher owns disk reloads, so this compatibility method republishes
2564        // the current immutable facade view instead of constructing a second
2565        // disk-reading Config authority.
2566        let _io = self.config_io_lock.lock().await;
2567        let mut config = self.config.write().await;
2568        let new_config = self
2569            .config_facade
2570            .as_ref()
2571            .map(|facade| load_facade_effective_config(facade, &self.app_data_dir))
2572            .unwrap_or_else(|| {
2573                Config::from_data_dir_without_publish(Some(self.app_data_dir.clone()))
2574            });
2575        new_config.publish_env_vars();
2576        *config = new_config.clone();
2577        new_config
2578    }
2579
2580    async fn persist_config_snapshot(&self, config: Config) -> anyhow::Result<()> {
2581        let data_dir = self.app_data_dir.clone();
2582        if self.config_facade.is_some() {
2583            tokio::task::spawn_blocking(move || {
2584                bamboo_config::persist_facade_effective_config(data_dir, &config)
2585            })
2586            .await
2587            .map_err(|e| anyhow::anyhow!("Config save task failed: {e}"))??;
2588            let sections = bamboo_config::SECTION_DESCRIPTORS
2589                .iter()
2590                .map(|descriptor| descriptor.id)
2591                .collect::<Vec<_>>();
2592            publish_exact_facade_commit(self.config_facade.as_ref(), &self.account_sink, &sections)
2593                .map_err(|error| anyhow::anyhow!(error.to_string()))?;
2594        } else {
2595            tokio::task::spawn_blocking(move || config.save_to_dir(data_dir))
2596                .await
2597                .map_err(|e| anyhow::anyhow!("Config save task failed: {e}"))??;
2598        }
2599        Ok(())
2600    }
2601
2602    /// Unified config update entrypoint.
2603    ///
2604    /// Invariants:
2605    /// - Build and validate a detached candidate
2606    /// - Persist to disk before publishing it in memory
2607    /// - Apply runtime side-effects last (provider reload, MCP reconcile)
2608    pub async fn update_config<F>(
2609        &self,
2610        update: F,
2611        effects: ConfigUpdateEffects,
2612    ) -> Result<Config, AppError>
2613    where
2614        F: FnOnce(&mut Config) -> Result<(), AppError>,
2615    {
2616        // Hold the config-IO lock across BOTH the in-memory mutation AND the disk
2617        // persist, so a concurrent reload_config can't read the disk in the gap
2618        // before we persist and then clobber this mutation with the stale copy
2619        // (#126). The lock is dropped before apply_config_effects — slow side
2620        // effects (provider reload) don't need to block reloads/other updates.
2621        let snapshot = {
2622            let _io = self.config_io_lock.lock().await;
2623            let (snapshot, enforcement_newly_off) = {
2624                let cfg = self.config.read().await;
2625                // Refuse the whole operation (no in-memory mutation, no disk
2626                // write) while a config-corruption recovery is pending
2627                // confirmation (#153) — `save_to_dir` would reject the persist
2628                // anyway, but checking here BEFORE `update()` runs keeps the
2629                // in-memory config frozen exactly at the recovered state
2630                // instead of silently drifting further from what's on disk.
2631                reject_if_recovery_pending(&cfg)?;
2632                let was_off = cfg.plugin_trust.enforcement_is_off();
2633                let mut candidate = cfg.clone();
2634                update(&mut candidate)?;
2635                // Backfill any missing connect.platforms id (#496) on the live
2636                // in-memory config itself — not just inside `save_to_dir`'s
2637                // internal save-copy — so the response this update returns
2638                // (and any GET immediately after) already reflects the id a
2639                // client can round-trip on its next PATCH.
2640                candidate.assign_connect_platform_ids();
2641                // Same live-vs-save-copy treatment for ciphertext (#516):
2642                // `save_to_dir` refreshes `*_encrypted` only on its save-time
2643                // clone, so a secret set through this entrypoint (e.g. a
2644                // provider instance created over HTTP) would otherwise stay
2645                // plaintext-only in memory — and the next settings-PATCH merge
2646                // (`build_merged_config`'s serde round-trip drops plaintext)
2647                // would lose the key entirely.
2648                candidate.refresh_encrypted_secrets().map_err(|e| {
2649                    AppError::InternalError(anyhow::anyhow!(
2650                        "Failed to refresh encrypted secrets: {e}"
2651                    ))
2652                })?;
2653                let newly_off = !was_off && candidate.plugin_trust.enforcement_is_off();
2654                (candidate, newly_off)
2655            };
2656            // Loud signal at the MOMENT plugin_trust.enforcement is flipped off
2657            // live (e.g. via `bamboo config set plugin_trust.enforcement off`
2658            // over HTTP), mirroring the boot-time warn in `AppState::new` — so
2659            // this security-relevant relaxation is never applied silently. Only
2660            // on the transition into `Off` (not every unrelated config write
2661            // while already off).
2662            if enforcement_newly_off {
2663                warn_plugin_trust_enforcement_off();
2664            }
2665            self.persist_config_snapshot(snapshot.clone())
2666                .await
2667                .map_err(|e| {
2668                    AppError::InternalError(anyhow::anyhow!("Failed to save config: {e}"))
2669                })?;
2670            let snapshot = self
2671                .config_facade
2672                .as_ref()
2673                .map(|facade| load_facade_effective_config(facade, &self.app_data_dir))
2674                .unwrap_or(snapshot);
2675            {
2676                let mut cfg = self.config.write().await;
2677                snapshot.publish_env_vars();
2678                *cfg = snapshot.clone();
2679            }
2680            snapshot
2681        };
2682
2683        self.apply_config_effects(snapshot.clone(), effects).await?;
2684        Ok(snapshot)
2685    }
2686
2687    /// Compatibility provider update whose credential and metadata documents
2688    /// share the recoverable config transaction manifest.
2689    pub async fn update_config_with_provider_credentials<F>(
2690        &self,
2691        update: F,
2692        provider_intents: std::collections::BTreeSet<String>,
2693        provider_instance_intents: std::collections::BTreeSet<String>,
2694        effects: ConfigUpdateEffects,
2695    ) -> Result<Config, AppError>
2696    where
2697        F: FnOnce(&mut Config) -> Result<(), AppError>,
2698    {
2699        if provider_intents.is_empty() && provider_instance_intents.is_empty() {
2700            return self.update_config(update, effects).await;
2701        }
2702        let config_facade = self.config_facade.clone();
2703        let account_sink = self.account_sink.clone();
2704        let (snapshot, enforcement_newly_off) = {
2705            let _io = self.config_io_lock.lock().await;
2706            let (mut candidate, enforcement_newly_off) = {
2707                let cfg = self.config.read().await;
2708                reject_if_recovery_pending(&cfg)?;
2709                let was_off = cfg.plugin_trust.enforcement_is_off();
2710                let mut candidate = cfg.clone();
2711                update(&mut candidate)?;
2712                // Provider plaintext may be present until the exact credential
2713                // transaction assigns its durable reference. Compare every
2714                // other section against a candidate whose provider projection
2715                // is replaced with the current durable domain; the exact
2716                // transaction below remains the sole provider validator.
2717                let mut non_provider_candidate = candidate.clone();
2718                apply_runtime_section(SectionId::Providers, &cfg, &mut non_provider_candidate);
2719                let mut changed =
2720                    bamboo_config::changed_facade_sections(&cfg, &non_provider_candidate).map_err(
2721                        |_| {
2722                            AppError::InternalError(anyhow::anyhow!(
2723                                "failed to compare modular configuration sections"
2724                            ))
2725                        },
2726                    )?;
2727                // The compatibility wire shape can normalize duplicated
2728                // external-broker metadata while leaving the actual subagents
2729                // module byte-for-byte equivalent. That is an unchanged echo,
2730                // not a second section mutation.
2731                if serde_json::to_value(cfg.subagents()).ok()
2732                    == serde_json::to_value(candidate.subagents()).ok()
2733                {
2734                    changed.retain(|section| *section != SectionId::Subagents);
2735                }
2736                if let Some(other) = changed
2737                    .into_iter()
2738                    .find(|section| *section != SectionId::Providers)
2739                {
2740                    return Err(AppError::BadRequest(format!(
2741                        "provider credential updates cannot be combined with {} changes; split the request",
2742                        other.descriptor().name
2743                    )));
2744                }
2745                candidate.assign_connect_platform_ids();
2746                candidate.refresh_encrypted_secrets().map_err(|error| {
2747                    AppError::InternalError(anyhow::anyhow!(
2748                        "Failed to refresh encrypted secrets: {error}"
2749                    ))
2750                })?;
2751                let newly_off = !was_off && candidate.plugin_trust.enforcement_is_off();
2752                (candidate, newly_off)
2753            };
2754            let data_dir = self.app_data_dir.clone();
2755            let candidate = tokio::task::spawn_blocking(move || {
2756                bamboo_config::persist_provider_instance_credential_transaction(
2757                    &data_dir,
2758                    &mut candidate,
2759                    &provider_intents,
2760                    &provider_instance_intents,
2761                )?;
2762                load_committed_effective_config(&data_dir)
2763            })
2764            .await
2765            .map_err(|error| {
2766                AppError::InternalError(anyhow::anyhow!(
2767                    "provider credential transaction task failed: {error}"
2768                ))
2769            })?
2770            .map_err(|error| match error {
2771                ConfigStoreError::Conflict { expected, actual } => {
2772                    AppError::ConfigConflict { expected, actual }
2773                }
2774                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2775                ConfigStoreError::Io(error) => AppError::StorageError(error),
2776                ConfigStoreError::Json(_) => {
2777                    AppError::BadRequest("configuration document is invalid".to_string())
2778                }
2779                ConfigStoreError::Watch(error) => {
2780                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2781                }
2782            })?;
2783            publish_exact_facade_commit(
2784                config_facade.as_ref(),
2785                &account_sink,
2786                &[SectionId::Credentials, SectionId::Providers],
2787            )?;
2788            {
2789                let mut cfg = self.config.write().await;
2790                candidate.publish_env_vars();
2791                *cfg = candidate.clone();
2792            }
2793            (candidate, enforcement_newly_off)
2794        };
2795        if enforcement_newly_off {
2796            warn_plugin_trust_enforcement_off();
2797        }
2798        self.apply_config_effects(snapshot.clone(), effects).await?;
2799        Ok(snapshot)
2800    }
2801
2802    /// Mutate user env vars through the recoverable credentials.json +
2803    /// config.json exact transaction. The detached task owns the mutation so
2804    /// request cancellation cannot strand durable metadata ahead of runtime.
2805    pub async fn update_env_var_credentials<F>(
2806        &self,
2807        expected_revision: u64,
2808        env_intents: std::collections::BTreeSet<String>,
2809        update: F,
2810    ) -> Result<(Config, u64), AppError>
2811    where
2812        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2813    {
2814        let config_io_lock = self.config_io_lock.clone();
2815        let config = self.config.clone();
2816        let app_data_dir = self.app_data_dir.clone();
2817        let account_sink = self.account_sink.clone();
2818        let config_facade = self.config_facade.clone();
2819        let transaction = tokio::spawn(async move {
2820            let _io = config_io_lock.lock().await;
2821            let mut candidate = {
2822                let current = config.read().await;
2823                reject_if_recovery_pending(&current)?;
2824                let mut candidate = current.clone();
2825                update(&mut candidate)?;
2826                candidate.assign_connect_platform_ids();
2827                candidate
2828            };
2829            let transaction_dir = app_data_dir.clone();
2830            let (candidate, revision) = tokio::task::spawn_blocking(move || {
2831                let revision = bamboo_config::persist_env_var_credential_transaction_at_revision(
2832                    &transaction_dir,
2833                    &mut candidate,
2834                    &env_intents,
2835                    expected_revision,
2836                )?;
2837                // Reload from the durable authority selected by the layout.
2838                // Active modular layouts never publish stale config.json.
2839                let committed = load_committed_effective_config(&transaction_dir)?;
2840                Ok::<_, ConfigStoreError>((committed, revision))
2841            })
2842            .await
2843            .map_err(|error| {
2844                AppError::InternalError(anyhow::anyhow!(
2845                    "env credential transaction task failed: {error}"
2846                ))
2847            })?
2848            .map_err(|error| match error {
2849                ConfigStoreError::Conflict { expected, actual } => {
2850                    AppError::ConfigConflict { expected, actual }
2851                }
2852                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2853                ConfigStoreError::Io(error) => AppError::StorageError(error),
2854                ConfigStoreError::Json(_) => {
2855                    AppError::BadRequest("configuration document is invalid".to_string())
2856                }
2857                ConfigStoreError::Watch(error) => {
2858                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2859                }
2860            })?;
2861            publish_exact_facade_commit(
2862                config_facade.as_ref(),
2863                &account_sink,
2864                &[SectionId::Credentials, SectionId::Env],
2865            )?;
2866            candidate.publish_env_vars();
2867            *config.write().await = candidate.clone();
2868            Ok::<_, AppError>((candidate, revision))
2869        });
2870        transaction.await.map_err(|error| {
2871            AppError::InternalError(anyhow::anyhow!(
2872                "env credential transaction task failed: {error}"
2873            ))
2874        })?
2875    }
2876
2877    /// Mutate notification metadata and ntfy/Bark credentials through the
2878    /// recoverable credentials.json + config.json exact transaction. The
2879    /// detached task completes durable commit and live publication even if the
2880    /// initiating HTTP request is cancelled.
2881    pub async fn update_notification_credentials<F>(
2882        &self,
2883        expected_revision: u64,
2884        secret_intents: std::collections::BTreeSet<String>,
2885        reset_domain: bool,
2886        update: F,
2887    ) -> Result<(Config, u64), AppError>
2888    where
2889        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2890    {
2891        let config_io_lock = self.config_io_lock.clone();
2892        let config = self.config.clone();
2893        let app_data_dir = self.app_data_dir.clone();
2894        let account_sink = self.account_sink.clone();
2895        let config_facade = self.config_facade.clone();
2896        let transaction = tokio::spawn(async move {
2897            let _io = config_io_lock.lock().await;
2898            let mut candidate = {
2899                let current = config.read().await;
2900                reject_if_recovery_pending(&current)?;
2901                let mut candidate = current.clone();
2902                update(&mut candidate)?;
2903                candidate
2904            };
2905            let transaction_dir = app_data_dir.clone();
2906            let (candidate, revision) = tokio::task::spawn_blocking(move || {
2907                let revision =
2908                    bamboo_config::persist_notification_credential_transaction_at_revision_with_reset(
2909                            &transaction_dir,
2910                            &mut candidate,
2911                            &secret_intents,
2912                            reset_domain,
2913                            expected_revision,
2914                        )?;
2915                let committed = load_committed_effective_config(&transaction_dir)?;
2916                Ok::<_, ConfigStoreError>((committed, revision))
2917            })
2918            .await
2919            .map_err(|error| {
2920                AppError::InternalError(anyhow::anyhow!(
2921                    "notification credential transaction task failed: {error}"
2922                ))
2923            })?
2924            .map_err(|error| match error {
2925                ConfigStoreError::Conflict { expected, actual } => {
2926                    AppError::ConfigConflict { expected, actual }
2927                }
2928                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2929                ConfigStoreError::Io(error) => AppError::StorageError(error),
2930                ConfigStoreError::Json(_) => {
2931                    AppError::BadRequest("configuration document is invalid".to_string())
2932                }
2933                ConfigStoreError::Watch(error) => {
2934                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2935                }
2936            })?;
2937            publish_exact_facade_commit(
2938                config_facade.as_ref(),
2939                &account_sink,
2940                &[SectionId::Credentials, SectionId::Notifications],
2941            )?;
2942            *config.write().await = candidate.clone();
2943            Ok::<_, AppError>((candidate, revision))
2944        });
2945        transaction.await.map_err(|error| {
2946            AppError::InternalError(anyhow::anyhow!(
2947                "notification credential transaction task failed: {error}"
2948            ))
2949        })?
2950    }
2951
2952    /// Replace connect metadata and explicitly touched platform credentials
2953    /// through the active connect-section + credential exact transaction.
2954    /// The detached task owns durable commit and runtime publication so a
2955    /// cancelled HTTP request cannot leave memory behind the committed pair.
2956    pub async fn update_connect_credentials<F>(
2957        &self,
2958        expected_revision: u64,
2959        secret_intents: bamboo_config::patch::ConnectSecretIntents,
2960        update: F,
2961    ) -> Result<(Config, u64), AppError>
2962    where
2963        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2964    {
2965        let config_io_lock = self.config_io_lock.clone();
2966        let config = self.config.clone();
2967        let app_data_dir = self.app_data_dir.clone();
2968        let account_sink = self.account_sink.clone();
2969        let config_facade = self.config_facade.clone();
2970        let transaction = tokio::spawn(async move {
2971            let _io = config_io_lock.lock().await;
2972            let mut candidate = {
2973                let current = config.read().await;
2974                reject_if_recovery_pending(&current)?;
2975                let mut candidate = current.clone();
2976                update(&mut candidate)?;
2977                candidate.assign_connect_platform_ids();
2978                candidate
2979            };
2980            let transaction_dir = app_data_dir.clone();
2981            let (candidate, revision) = tokio::task::spawn_blocking(move || {
2982                let revision = bamboo_config::persist_connect_credential_transaction_at_revision(
2983                    &transaction_dir,
2984                    &mut candidate,
2985                    &secret_intents,
2986                    expected_revision,
2987                )?;
2988                let committed = load_committed_effective_config(&transaction_dir)?;
2989                Ok::<_, ConfigStoreError>((committed, revision))
2990            })
2991            .await
2992            .map_err(|error| {
2993                AppError::InternalError(anyhow::anyhow!(
2994                    "connect credential transaction task failed: {error}"
2995                ))
2996            })?
2997            .map_err(|error| match error {
2998                ConfigStoreError::Conflict { expected, actual } => {
2999                    AppError::ConfigConflict { expected, actual }
3000                }
3001                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3002                ConfigStoreError::Io(error) => AppError::StorageError(error),
3003                ConfigStoreError::Json(_) => {
3004                    AppError::BadRequest("configuration document is invalid".to_string())
3005                }
3006                ConfigStoreError::Watch(error) => {
3007                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
3008                }
3009            })?;
3010            publish_exact_facade_commit(
3011                config_facade.as_ref(),
3012                &account_sink,
3013                &[SectionId::Credentials, SectionId::Connect],
3014            )?;
3015            *config.write().await = candidate.clone();
3016            Ok::<_, AppError>((candidate, revision))
3017        });
3018        transaction.await.map_err(|error| {
3019            AppError::InternalError(anyhow::anyhow!(
3020                "connect credential transaction task failed: {error}"
3021            ))
3022        })?
3023    }
3024
3025    /// Mutate access-control metadata and verifier records through the active
3026    /// access-control section + credential exact transaction.
3027    pub async fn update_access_control_credentials<F>(
3028        &self,
3029        expected_revision: u64,
3030        password_intent: bool,
3031        device_intents: std::collections::BTreeSet<String>,
3032        update: F,
3033    ) -> Result<(Config, u64), AppError>
3034    where
3035        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
3036    {
3037        let config_io_lock = self.config_io_lock.clone();
3038        let config = self.config.clone();
3039        let app_data_dir = self.app_data_dir.clone();
3040        let account_sink = self.account_sink.clone();
3041        let config_facade = self.config_facade.clone();
3042        let transaction = tokio::spawn(async move {
3043            let _io = config_io_lock.lock().await;
3044            let mut candidate = {
3045                let current = config.read().await;
3046                reject_if_recovery_pending(&current)?;
3047                let mut candidate = current.clone();
3048                update(&mut candidate)?;
3049                candidate
3050            };
3051            let transaction_dir = app_data_dir.clone();
3052            let (candidate, revision) = tokio::task::spawn_blocking(move || {
3053                let revision =
3054                    bamboo_config::persist_access_control_credential_transaction_at_revision(
3055                        &transaction_dir,
3056                        &mut candidate,
3057                        password_intent,
3058                        &device_intents,
3059                        expected_revision,
3060                    )?;
3061                let committed = load_committed_effective_config(&transaction_dir)?;
3062                Ok::<_, ConfigStoreError>((committed, revision))
3063            })
3064            .await
3065            .map_err(|error| {
3066                AppError::InternalError(anyhow::anyhow!(
3067                    "access-control credential transaction task failed: {error}"
3068                ))
3069            })?
3070            .map_err(|error| match error {
3071                ConfigStoreError::Conflict { expected, actual } => {
3072                    AppError::ConfigConflict { expected, actual }
3073                }
3074                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3075                ConfigStoreError::Io(error) => AppError::StorageError(error),
3076                ConfigStoreError::Json(_) => {
3077                    AppError::BadRequest("configuration document is invalid".to_string())
3078                }
3079                ConfigStoreError::Watch(error) => {
3080                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
3081                }
3082            })?;
3083            publish_exact_facade_commit(
3084                config_facade.as_ref(),
3085                &account_sink,
3086                &[SectionId::Credentials, SectionId::AccessControl],
3087            )?;
3088            *config.write().await = candidate.clone();
3089            Ok::<_, AppError>((candidate, revision))
3090        });
3091        transaction.await.map_err(|error| {
3092            AppError::InternalError(anyhow::anyhow!(
3093                "access-control credential transaction task failed: {error}"
3094            ))
3095        })?
3096    }
3097
3098    /// Mutate cluster-fabric node metadata and SSH credentials through the
3099    /// recoverable credentials.json + config.json exact transaction. The
3100    /// detached task owns durable commit, committed-root reload, and live
3101    /// publication so request cancellation cannot leave runtime behind disk.
3102    pub async fn update_cluster_fabric_credentials<F>(
3103        &self,
3104        expected_revision: u64,
3105        node_intents: std::collections::BTreeSet<String>,
3106        update: F,
3107    ) -> Result<(Config, u64), AppError>
3108    where
3109        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
3110    {
3111        let config_io_lock = self.config_io_lock.clone();
3112        let config = self.config.clone();
3113        let app_data_dir = self.app_data_dir.clone();
3114        let account_sink = self.account_sink.clone();
3115        let config_facade = self.config_facade.clone();
3116        let transaction = tokio::spawn(async move {
3117            let _io = config_io_lock.lock().await;
3118            let mut candidate = {
3119                let current = config.read().await;
3120                reject_if_recovery_pending(&current)?;
3121                let mut candidate = current.clone();
3122                update(&mut candidate)?;
3123                candidate
3124            };
3125            let transaction_dir = app_data_dir.clone();
3126            let (candidate, revision) = tokio::task::spawn_blocking(move || {
3127                let revision =
3128                    bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
3129                        &transaction_dir,
3130                        &mut candidate,
3131                        &node_intents,
3132                        expected_revision,
3133                    )?;
3134                let committed = load_committed_effective_config(&transaction_dir)?;
3135                Ok::<_, ConfigStoreError>((committed, revision))
3136            })
3137            .await
3138            .map_err(|error| {
3139                AppError::InternalError(anyhow::anyhow!(
3140                    "cluster credential transaction task failed: {error}"
3141                ))
3142            })?
3143            .map_err(|error| match error {
3144                ConfigStoreError::Conflict { expected, actual } => {
3145                    AppError::ConfigConflict { expected, actual }
3146                }
3147                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3148                ConfigStoreError::Io(error) => AppError::StorageError(error),
3149                ConfigStoreError::Json(_) => {
3150                    AppError::BadRequest("configuration document is invalid".to_string())
3151                }
3152                ConfigStoreError::Watch(error) => {
3153                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
3154                }
3155            })?;
3156            publish_exact_facade_commit(
3157                config_facade.as_ref(),
3158                &account_sink,
3159                &[SectionId::Credentials, SectionId::ClusterFabric],
3160            )?;
3161            *config.write().await = candidate.clone();
3162            Ok::<_, AppError>((candidate, revision))
3163        });
3164        transaction.await.map_err(|error| {
3165            AppError::InternalError(anyhow::anyhow!(
3166                "cluster credential transaction task failed: {error}"
3167            ))
3168        })?
3169    }
3170
3171    /// Persist proxy authentication through the isolated credential store and
3172    /// publish the detached runtime candidate only after the exact transaction
3173    /// has durably committed.
3174    pub async fn update_proxy_auth_credential(
3175        &self,
3176        auth: Option<bamboo_config::ProxyAuth>,
3177        expected_revision: u64,
3178        effects: ConfigUpdateEffects,
3179    ) -> Result<
3180        (
3181            Config,
3182            bamboo_config::CredentialStatus,
3183            bamboo_config::CredentialStoreHealth,
3184        ),
3185        AppError,
3186    > {
3187        self.update_core_with_proxy_credential(expected_revision, effects, move |candidate| {
3188            candidate.proxy_auth = auth;
3189        })
3190        .await
3191    }
3192
3193    async fn update_core_with_proxy_credential<F>(
3194        &self,
3195        expected_revision: u64,
3196        effects: ConfigUpdateEffects,
3197        update: F,
3198    ) -> Result<
3199        (
3200            Config,
3201            bamboo_config::CredentialStatus,
3202            bamboo_config::CredentialStoreHealth,
3203        ),
3204        AppError,
3205    >
3206    where
3207        F: FnOnce(&mut Config) + Send + 'static,
3208    {
3209        let config_io_lock = self.config_io_lock.clone();
3210        let config = self.config.clone();
3211        let app_data_dir = self.app_data_dir.clone();
3212        let credential_store = self.credential_store.clone();
3213        let provider_registry = self.provider_registry.clone();
3214        let provider = self.provider.clone();
3215        let mcp_manager = self.mcp_manager.clone();
3216        let config_facade = self.config_facade.clone();
3217        let account_sink = self.account_sink.clone();
3218
3219        // This task owns the mutation after dispatch. Dropping the request's
3220        // JoinHandle does not cancel it, so the blocking durable transaction,
3221        // live publication, and runtime convergence complete as one serialized
3222        // operation even when the caller disconnects.
3223        let transaction = tokio::spawn(async move {
3224            let _io = config_io_lock.lock().await;
3225            let mut candidate = {
3226                let cfg = config.read().await;
3227                reject_if_recovery_pending(&cfg)?;
3228                let mut candidate = cfg.clone();
3229                update(&mut candidate);
3230                candidate.assign_connect_platform_ids();
3231                candidate.refresh_encrypted_secrets().map_err(|error| {
3232                    AppError::InternalError(anyhow::anyhow!(
3233                        "Failed to refresh encrypted secrets: {error}"
3234                    ))
3235                })?;
3236                candidate
3237            };
3238            let transaction_dir = app_data_dir.clone();
3239            let status_reference =
3240                candidate
3241                    .proxy_auth_credential_ref
3242                    .clone()
3243                    .unwrap_or_else(|| {
3244                        bamboo_config::CredentialRef::parse("proxy.default.auth")
3245                            .expect("canonical proxy credential reference is valid")
3246                    });
3247            let (candidate, reference) = tokio::task::spawn_blocking(move || {
3248                bamboo_config::persist_proxy_auth_credential_transaction_at_revision(
3249                    &transaction_dir,
3250                    &mut candidate,
3251                    expected_revision,
3252                )?;
3253                Ok::<_, ConfigStoreError>((
3254                    load_committed_effective_config(&transaction_dir)?,
3255                    status_reference,
3256                ))
3257            })
3258            .await
3259            .map_err(|error| {
3260                AppError::InternalError(anyhow::anyhow!(
3261                    "proxy credential transaction task failed: {error}"
3262                ))
3263            })?
3264            .map_err(|error| match error {
3265                ConfigStoreError::Conflict { expected, actual } => {
3266                    AppError::ConfigConflict { expected, actual }
3267                }
3268                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3269                ConfigStoreError::Io(error) => AppError::StorageError(error),
3270                ConfigStoreError::Json(_) => {
3271                    AppError::BadRequest("configuration document is invalid".to_string())
3272                }
3273                ConfigStoreError::Watch(error) => {
3274                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
3275                }
3276            })?;
3277
3278            publish_exact_facade_commit(
3279                config_facade.as_ref(),
3280                &account_sink,
3281                &[SectionId::Credentials, SectionId::Core],
3282            )?;
3283
3284            // No fallible metadata read occurs before publication. Once the
3285            // transaction commits, a response error can no longer leave live
3286            // config behind its durable credential/config pair.
3287            candidate.publish_env_vars();
3288            *config.write().await = candidate.clone();
3289
3290            if effects.reload_provider {
3291                match bamboo_llm::ProviderRegistry::from_config(&candidate, app_data_dir.clone())
3292                    .await
3293                {
3294                    Ok(candidate_registry) => {
3295                        if let Some(candidate_provider) = candidate_registry.get_default() {
3296                            let mut live_provider = provider.write().await;
3297                            provider_registry.replace_with(candidate_registry);
3298                            *live_provider = candidate_provider;
3299                        } else {
3300                            tracing::warn!(
3301                                "proxy auth committed but provider reload had no default provider"
3302                            );
3303                        }
3304                    }
3305                    Err(error) => {
3306                        tracing::warn!(
3307                            error = %error,
3308                            "proxy auth committed but provider reload failed"
3309                        );
3310                    }
3311                }
3312            }
3313
3314            if effects.reconcile_mcp {
3315                mcp_manager.reconcile_from_config(&candidate.mcp).await;
3316            }
3317
3318            let (status, health) =
3319                credential_store
3320                    .status_with_health(&reference)
3321                    .map_err(|error| match error {
3322                        ConfigStoreError::Conflict { expected, actual } => {
3323                            AppError::ConfigConflict { expected, actual }
3324                        }
3325                        ConfigStoreError::Validation(_) | ConfigStoreError::Json(_) => {
3326                            AppError::InternalError(anyhow::anyhow!(
3327                                "credential store validation failed"
3328                            ))
3329                        }
3330                        ConfigStoreError::Io(error) => AppError::StorageError(error),
3331                        ConfigStoreError::Watch(error) => AppError::InternalError(anyhow::anyhow!(
3332                            "configuration watch failed: {error}"
3333                        )),
3334                    })?;
3335            Ok::<_, AppError>((candidate, status, health))
3336        });
3337        transaction.await.map_err(|error| {
3338            AppError::InternalError(anyhow::anyhow!(
3339                "proxy credential mutation task failed: {error}"
3340            ))
3341        })?
3342    }
3343
3344    /// Replace the full config (used for JSON merge endpoints).
3345    pub async fn replace_config(
3346        &self,
3347        mut new_config: Config,
3348        effects: ConfigUpdateEffects,
3349    ) -> Result<Config, AppError> {
3350        // Backfill any missing connect.platforms id (#496) up front, before
3351        // any of the clones below are taken, so the in-memory config, the
3352        // disk-persisted snapshot, and the value this call returns to the
3353        // caller (the settings-merge HTTP response) all agree on the same
3354        // ids — mirrors the `update_config` treatment above.
3355        new_config.assign_connect_platform_ids();
3356        // Keep ciphertext in sync with plaintext on the config that becomes
3357        // the live in-memory state — same #516 rationale as `update_config`.
3358        new_config.refresh_encrypted_secrets().map_err(|e| {
3359            AppError::InternalError(anyhow::anyhow!("Failed to refresh encrypted secrets: {e}"))
3360        })?;
3361
3362        // Same #126 serialization as update_config: mutate + persist under the
3363        // config-IO lock so a reload can't interleave; effects run unlocked.
3364        let new_config = {
3365            let _io = self.config_io_lock.lock().await;
3366            let was_off = {
3367                let cfg = self.config.read().await;
3368                // Same guard as `update_config` (#153): a full-config replace
3369                // must not silently blow away an unconfirmed recovery either.
3370                reject_if_recovery_pending(&cfg)?;
3371                cfg.plugin_trust.enforcement_is_off()
3372            };
3373            self.persist_config_snapshot(new_config.clone())
3374                .await
3375                .map_err(|e| {
3376                    AppError::InternalError(anyhow::anyhow!("Failed to save config: {e}"))
3377                })?;
3378            let published = self
3379                .config_facade
3380                .as_ref()
3381                .map(|facade| load_facade_effective_config(facade, &self.app_data_dir))
3382                .unwrap_or(new_config);
3383            let enforcement_newly_off = !was_off && published.plugin_trust.enforcement_is_off();
3384            published.publish_env_vars();
3385            *self.config.write().await = published.clone();
3386            // Same live signal as `update_config` — a full-config replace (JSON
3387            // merge / PATCH endpoints) that transitions plugin_trust.enforcement
3388            // into `Off` must warn just as loudly as a targeted set.
3389            if enforcement_newly_off {
3390                warn_plugin_trust_enforcement_off();
3391            }
3392            published
3393        };
3394
3395        self.apply_config_effects(new_config.clone(), effects)
3396            .await?;
3397        Ok(new_config)
3398    }
3399
3400    async fn apply_config_effects(
3401        &self,
3402        new_config: Config,
3403        effects: ConfigUpdateEffects,
3404    ) -> Result<(), AppError> {
3405        if effects.reload_provider {
3406            self.reload_provider().await.map_err(|e| {
3407                AppError::InternalError(anyhow::anyhow!(
3408                    "Failed to reload provider after updating config: {e}"
3409                ))
3410            })?;
3411        }
3412
3413        if effects.reconcile_mcp {
3414            self.mcp_manager
3415                .reconcile_from_config(&new_config.mcp)
3416                .await;
3417        }
3418
3419        Ok(())
3420    }
3421
3422    /// Resolve a pending config-corruption recovery (#153; see
3423    /// [`bamboo_config::ConfigRecoveryStatus`]).
3424    ///
3425    /// - `accept = true`: confirms the recovery and persists it to
3426    ///   `config.json` in the same step ([`Config::confirm_recovery_and_save_to_dir`]),
3427    ///   then clears the pending flag — the config is no longer "pending
3428    ///   confirmation" once this returns `Ok`.
3429    /// - `accept = false`: a no-op that leaves everything untouched — disk,
3430    ///   in-memory config, and the pending flag are all left exactly as they
3431    ///   were. `config.json` stays refused-to-write (see `save_to_dir`) until
3432    ///   either a later `accept = true` call or the user hand-fixes
3433    ///   `config.json` and the process reloads/restarts.
3434    ///
3435    /// Errors with [`AppError::BadRequest`] if there's no pending recovery to
3436    /// resolve.
3437    pub async fn confirm_config_recovery(&self, accept: bool) -> Result<Config, AppError> {
3438        let _io = self.config_io_lock.lock().await;
3439
3440        if !accept {
3441            let cfg = self.config.read().await;
3442            return match cfg.recovery_status() {
3443                Some(_) => Ok(cfg.clone()),
3444                None => Err(AppError::BadRequest(
3445                    "No pending config-corruption recovery to resolve".to_string(),
3446                )),
3447            };
3448        }
3449
3450        let mut candidate = {
3451            let cfg = self.config.read().await;
3452            match cfg.recovery_status() {
3453                Some(_) => cfg.clone(),
3454                None => {
3455                    return Err(AppError::BadRequest(
3456                        "No pending config-corruption recovery to resolve".to_string(),
3457                    ))
3458                }
3459            }
3460        };
3461
3462        let data_dir = self.app_data_dir.clone();
3463        candidate = tokio::task::spawn_blocking(move || {
3464            candidate
3465                .confirm_recovery_and_save_to_dir(data_dir)
3466                .map(|_| candidate)
3467        })
3468        .await
3469        .map_err(|e| {
3470            AppError::InternalError(anyhow::anyhow!("Config recovery-confirm task failed: {e}"))
3471        })?
3472        .map_err(|e| {
3473            AppError::InternalError(anyhow::anyhow!("Failed to save recovered config: {e}"))
3474        })?;
3475
3476        {
3477            let mut cfg = self.config.write().await;
3478            *cfg = candidate.clone();
3479            cfg.publish_env_vars();
3480        }
3481
3482        Ok(candidate)
3483    }
3484}
3485
3486/// Short-circuit config-mutating entrypoints while a config-corruption
3487/// recovery is pending confirmation (#153): `save_to_dir` would refuse the
3488/// disk write anyway, but rejecting here — before any in-memory mutation
3489/// runs — keeps the in-memory config frozen at exactly the recovered state
3490/// instead of drifting further out of sync with what's actually on disk.
3491/// Resolve the pending recovery via `AppState::confirm_config_recovery`
3492/// first.
3493fn reject_if_recovery_pending(cfg: &Config) -> Result<(), AppError> {
3494    if let Some(status) = cfg.recovery_status() {
3495        if !status.confirmed {
3496            return Err(AppError::ConfigRecoveryPending(format!(
3497                "config.json was recovered from corruption ({:?}) and is awaiting \
3498                 confirmation; confirm or reject the recovery (see /bamboo/config/recovery-status \
3499                 and /bamboo/config/recovery/confirm) before changing settings",
3500                status.source
3501            )));
3502        }
3503    }
3504    Ok(())
3505}
3506
3507/// The prominent warning emitted whenever `plugin_trust.enforcement` is (or
3508/// becomes) `Off` — that setting silently downgrades EVERY subsequent `url`
3509/// plugin install/update to skip the host allowlist, signature, and
3510/// checksum-required-by-default layers, with no per-install flag needed (see
3511/// `crate::plugin_source`'s module docs). Factored into one function so the
3512/// boot-time signal (`AppState::new`) and the live-apply signal
3513/// (`update_config`/`replace_config`, covering the HTTP `config set` / PATCH
3514/// paths) emit the EXACT same message — no drift, and no trigger can flip
3515/// enforcement off silently.
3516pub(crate) fn warn_plugin_trust_enforcement_off() {
3517    tracing::warn!(
3518        "plugin_trust.enforcement is OFF — plugin installs from ANY URL are accepted \
3519         without host/signature/checksum verification (config.json plugin_trust.enforcement)"
3520    );
3521}
3522
3523#[cfg(test)]
3524mod live_reload_tests {
3525    use super::*;
3526    use bamboo_agent_core::{Message, ToolSchema};
3527    use bamboo_llm::{LLMError, LLMStream};
3528    use bamboo_mcp::{McpServerConfig, ReconnectConfig, StdioConfig};
3529
3530    struct WorkingProvider;
3531
3532    fn disabled_mcp_config(id: &str) -> McpConfig {
3533        McpConfig {
3534            version: 1,
3535            servers: vec![McpServerConfig {
3536                id: id.to_string(),
3537                name: None,
3538                enabled: false,
3539                transport: TransportConfig::Stdio(StdioConfig {
3540                    command: "unused-disabled-command".to_string(),
3541                    args: vec![],
3542                    cwd: None,
3543                    env: std::collections::HashMap::new(),
3544                    env_encrypted: std::collections::HashMap::new(),
3545                    env_credential_refs: std::collections::HashMap::new(),
3546                    startup_timeout_ms: 100,
3547                }),
3548                request_timeout_ms: 100,
3549                healthcheck_interval_ms: 100,
3550                reconnect: ReconnectConfig::default(),
3551                allowed_tools: vec![],
3552                denied_tools: vec![],
3553            }],
3554        }
3555    }
3556
3557    fn mcp_document_bytes(revision: u64, config: &McpConfig) -> Vec<u8> {
3558        serde_json::to_vec_pretty(&serde_json::json!({
3559            "schema_version": 1,
3560            "revision": revision,
3561            "data": config,
3562        }))
3563        .unwrap()
3564    }
3565
3566    #[test]
3567    fn touched_shared_mcp_refs_stage_replacements_and_preserve_surviving_clears() {
3568        let shared =
3569            bamboo_config::CredentialRef::parse("mcp.shared.env_token".to_string()).unwrap();
3570        let mut current = disabled_mcp_config("first");
3571        let mut second = current.servers[0].clone();
3572        second.id = "second".to_string();
3573        current.servers.push(second);
3574        for server in &mut current.servers {
3575            let TransportConfig::Stdio(stdio) = &mut server.transport else {
3576                unreachable!()
3577            };
3578            stdio
3579                .env
3580                .insert("TOKEN".to_string(), "old-shared-secret".to_string());
3581            stdio
3582                .env_credential_refs
3583                .insert("TOKEN".to_string(), shared.as_str().to_string());
3584        }
3585        let touched = BTreeSet::from([shared]);
3586
3587        let mut replace = current.clone();
3588        for server in &mut replace.servers {
3589            let TransportConfig::Stdio(stdio) = &mut server.transport else {
3590                unreachable!()
3591            };
3592            stdio.env.get_mut("TOKEN").unwrap().clear();
3593        }
3594        let TransportConfig::Stdio(first) = &mut replace.servers[0].transport else {
3595            unreachable!()
3596        };
3597        first
3598            .env
3599            .insert("TOKEN".to_string(), "new-shared-secret".to_string());
3600        materialize_mcp_touched_replacements(&mut replace, &touched).unwrap();
3601        retain_mcp_credentials(&current, &mut replace, &touched);
3602        for server in &replace.servers {
3603            let TransportConfig::Stdio(stdio) = &server.transport else {
3604                unreachable!()
3605            };
3606            assert_eq!(stdio.env["TOKEN"], "new-shared-secret");
3607        }
3608
3609        let mut clear_one = current.clone();
3610        for server in &mut clear_one.servers {
3611            let TransportConfig::Stdio(stdio) = &mut server.transport else {
3612                unreachable!()
3613            };
3614            stdio.env.get_mut("TOKEN").unwrap().clear();
3615        }
3616        let TransportConfig::Stdio(first) = &mut clear_one.servers[0].transport else {
3617            unreachable!()
3618        };
3619        first.env.remove("TOKEN");
3620        first.env_credential_refs.remove("TOKEN");
3621        materialize_mcp_touched_replacements(&mut clear_one, &touched).unwrap();
3622        retain_mcp_credentials(&current, &mut clear_one, &touched);
3623        let TransportConfig::Stdio(first) = &clear_one.servers[0].transport else {
3624            unreachable!()
3625        };
3626        assert!(!first.env.contains_key("TOKEN"));
3627        assert!(!first.env_credential_refs.contains_key("TOKEN"));
3628        let TransportConfig::Stdio(second) = &clear_one.servers[1].transport else {
3629            unreachable!()
3630        };
3631        assert_eq!(second.env["TOKEN"], "old-shared-secret");
3632        assert_eq!(second.env_credential_refs["TOKEN"], "mcp.shared.env_token");
3633
3634        let header_ref =
3635            bamboo_config::CredentialRef::parse("mcp.shared.header_token".to_string()).unwrap();
3636        let current_http = McpConfig {
3637            version: 1,
3638            servers: vec![McpServerConfig {
3639                id: "http".to_string(),
3640                name: None,
3641                enabled: false,
3642                transport: TransportConfig::Sse(bamboo_mcp::SseConfig {
3643                    url: "https://example.test/sse".to_string(),
3644                    headers: vec![bamboo_mcp::HeaderConfig {
3645                        name: "Authorization".to_string(),
3646                        value: "old-header-secret".to_string(),
3647                        value_encrypted: None,
3648                        credential_ref: Some(header_ref.as_str().to_string()),
3649                    }],
3650                    connect_timeout_ms: 100,
3651                }),
3652                request_timeout_ms: 100,
3653                healthcheck_interval_ms: 100,
3654                reconnect: ReconnectConfig::default(),
3655                allowed_tools: vec![],
3656                denied_tools: vec![],
3657            }],
3658        };
3659        let mut delete_all_headers = current_http.clone();
3660        let TransportConfig::Sse(candidate) = &mut delete_all_headers.servers[0].transport else {
3661            unreachable!()
3662        };
3663        candidate.headers.clear();
3664        let touched = BTreeSet::from([header_ref]);
3665        materialize_mcp_touched_replacements(&mut delete_all_headers, &touched).unwrap();
3666        retain_mcp_credentials(&current_http, &mut delete_all_headers, &touched);
3667        let TransportConfig::Sse(candidate) = &delete_all_headers.servers[0].transport else {
3668            unreachable!()
3669        };
3670        assert!(candidate.headers.is_empty());
3671    }
3672
3673    fn install_unrecoverable_pending_provider_migration(dir: &Path) {
3674        let transaction_id = uuid::Uuid::new_v4().to_string();
3675        std::fs::write(
3676            dir.join("config.json"),
3677            br#"{"providers":{"openai":{"model":"root-lkg"}}}"#,
3678        )
3679        .unwrap();
3680        std::fs::write(
3681            dir.join("providers.json"),
3682            br#"{"schema_version":1,"revision":2,"data":{"openai":{"model":"partial-must-not-load","credential_ref":"provider.openai.api_key"}}}"#,
3683        )
3684        .unwrap();
3685        std::fs::write(
3686            dir.join("config-credential-migration.json"),
3687            serde_json::to_vec_pretty(&serde_json::json!({
3688                "version": 1,
3689                "transaction_id": transaction_id.clone(),
3690                "stage_dir": format!(".config-credential-stage-v1-{transaction_id}"),
3691                "state": "pending",
3692                "files": [
3693                    {
3694                        "name": "credentials.json",
3695                        "staged_name": "credentials.json",
3696                        "sha256": "0".repeat(64),
3697                        "sensitive": true
3698                    },
3699                    {
3700                        "name": "providers.json",
3701                        "staged_name": "providers.json",
3702                        "sha256": "1".repeat(64),
3703                        "original_sha256": "2".repeat(64),
3704                        "migration_generation": 2,
3705                        "sensitive": false
3706                    }
3707                ]
3708            }))
3709            .unwrap(),
3710        )
3711        .unwrap();
3712    }
3713
3714    async fn wait_for_mcp_health(
3715        state: &AppState,
3716        status: SectionStatus,
3717        minimum_revision: u64,
3718    ) -> ConfigLiveHealth {
3719        match tokio::time::timeout(Duration::from_secs(4), async {
3720            loop {
3721                let health = state
3722                    .mcp_config_live_health
3723                    .read()
3724                    .unwrap_or_else(|poisoned| poisoned.into_inner())
3725                    .clone();
3726                if health.status == status && health.revision >= minimum_revision {
3727                    break health;
3728                }
3729                tokio::time::sleep(Duration::from_millis(20)).await;
3730            }
3731        })
3732        .await
3733        {
3734            Ok(health) => health,
3735            Err(_) => panic!(
3736                "MCP health transition timed out: {:?}",
3737                state
3738                    .mcp_config_live_health
3739                    .read()
3740                    .unwrap_or_else(|poisoned| poisoned.into_inner())
3741                    .clone()
3742            ),
3743        }
3744    }
3745
3746    async fn next_config_event(
3747        feed: &mut tokio::sync::broadcast::Receiver<Arc<bamboo_engine::events::ChangeEvent>>,
3748        expected_section: &str,
3749    ) -> AgentEvent {
3750        tokio::time::timeout(Duration::from_secs(3), async {
3751            loop {
3752                let envelope = feed.recv().await.expect("account feed remains open");
3753                match &envelope.event {
3754                    AgentEvent::ConfigChanged { section, .. }
3755                    | AgentEvent::ConfigInvalid { section, .. }
3756                    | AgentEvent::ConfigRecovered { section, .. }
3757                        if section == expected_section =>
3758                    {
3759                        break envelope.event.clone();
3760                    }
3761                    _ => {}
3762                }
3763            }
3764        })
3765        .await
3766        .expect("config event timed out")
3767    }
3768
3769    async fn next_mcp_config_event(
3770        feed: &mut tokio::sync::broadcast::Receiver<Arc<bamboo_engine::events::ChangeEvent>>,
3771    ) -> AgentEvent {
3772        next_config_event(feed, "mcp").await
3773    }
3774
3775    async fn wait_for_facade_health(
3776        state: &AppState,
3777        id: SectionId,
3778        status: SectionStatus,
3779        revision: u64,
3780    ) -> bamboo_config::SectionHealth {
3781        tokio::time::timeout(Duration::from_secs(4), async {
3782            loop {
3783                let health = state
3784                    .config_facade
3785                    .as_ref()
3786                    .expect("production state owns a facade")
3787                    .registry()
3788                    .health()
3789                    .unwrap()
3790                    .into_iter()
3791                    .find(|health| health.section == id)
3792                    .unwrap();
3793                if health.status == status && health.revision == revision {
3794                    break health;
3795                }
3796                tokio::time::sleep(Duration::from_millis(20)).await;
3797            }
3798        })
3799        .await
3800        .expect("facade health transition timed out")
3801    }
3802
3803    #[test]
3804    fn initial_provider_health_validates_primary_and_backup() {
3805        let dir = tempfile::tempdir().unwrap();
3806        let store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
3807        let missing = initial_provider_health(&store);
3808        assert_eq!(missing.status, SectionStatus::Missing);
3809        assert_eq!(missing.source_kind, SectionSourceKind::Default);
3810
3811        std::fs::write(dir.path().join("providers.json"), b"{broken").unwrap();
3812        let invalid = initial_provider_health(&store);
3813        assert_eq!(invalid.status, SectionStatus::Invalid);
3814        assert_eq!(invalid.source_kind, SectionSourceKind::File);
3815
3816        std::fs::write(dir.path().join("providers.json.bak"), b"{}").unwrap();
3817        let recovered = initial_provider_health(&store);
3818        assert_eq!(recovered.status, SectionStatus::Degraded);
3819        assert_eq!(recovered.source_kind, SectionSourceKind::Backup);
3820        assert!(recovered
3821            .last_error
3822            .as_deref()
3823            .unwrap()
3824            .contains("last-known-good backup"));
3825
3826        std::fs::write(dir.path().join("providers.json"), b"{}").unwrap();
3827        let healthy = initial_provider_health(&store);
3828        assert_eq!(healthy.status, SectionStatus::Healthy);
3829        assert_eq!(healthy.source_kind, SectionSourceKind::File);
3830    }
3831
3832    #[tokio::test]
3833    async fn unrecoverable_pending_manifest_never_publishes_partial_provider_state() {
3834        let _key = bamboo_config::encryption::set_test_encryption_key([0x6c; 32]);
3835        let dir = tempfile::tempdir().unwrap();
3836        install_unrecoverable_pending_provider_migration(dir.path());
3837
3838        let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
3839        assert_eq!(
3840            loaded.providers().openai.as_ref().unwrap().model.as_deref(),
3841            Some("root-lkg")
3842        );
3843        let store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
3844        let health = initial_provider_health(&store);
3845        assert_eq!(health.status, SectionStatus::Degraded);
3846        assert!(health
3847            .last_error
3848            .as_deref()
3849            .unwrap()
3850            .contains("migration is pending"));
3851        let error = match load_and_prepare_provider_candidate(&store, 0, loaded).await {
3852            Ok(_) => panic!("pending migration must reject provider candidate"),
3853            Err(error) => error,
3854        };
3855        assert!(error.message.contains("retaining last-known-good runtime"));
3856        assert!(!error.message.contains("partial-must-not-load"));
3857
3858        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3859        assert_eq!(
3860            state
3861                .config
3862                .read()
3863                .await
3864                .providers()
3865                .openai
3866                .as_ref()
3867                .unwrap()
3868                .model
3869                .as_deref(),
3870            Some("root-lkg")
3871        );
3872        assert_eq!(
3873            state
3874                .config_live_health
3875                .read()
3876                .unwrap_or_else(|poisoned| poisoned.into_inner())
3877                .status,
3878            SectionStatus::Degraded
3879        );
3880    }
3881
3882    #[async_trait::async_trait]
3883    impl LLMProvider for WorkingProvider {
3884        async fn chat_stream(
3885            &self,
3886            _messages: &[Message],
3887            _tools: &[ToolSchema],
3888            _max_output_tokens: Option<u32>,
3889            _model: &str,
3890        ) -> Result<LLMStream, LLMError> {
3891            Err(LLMError::Api("working-provider-marker".to_string()))
3892        }
3893    }
3894
3895    #[tokio::test]
3896    async fn cancelled_provider_put_cannot_commit_before_publication_guards() {
3897        let _key = bamboo_config::encryption::set_test_encryption_key([0x53; 32]);
3898        let dir = tempfile::tempdir().unwrap();
3899        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3900        let secret = "provider-cancel-secret";
3901        let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
3902        bamboo_config::CredentialStore::open(dir.path())
3903            .replace(
3904                reference.clone(),
3905                secret,
3906                bamboo_config::CredentialSource::User,
3907                0,
3908            )
3909            .unwrap();
3910        {
3911            let mut config = state.config.write().await;
3912            config.provider = "openai".to_string();
3913            *config.providers_mut() = ProviderConfigs {
3914                openai: Some(bamboo_config::OpenAIConfig {
3915                    api_key: secret.to_string(),
3916                    credential_ref: Some(reference),
3917                    ..Default::default()
3918                }),
3919                ..Default::default()
3920            };
3921        }
3922        let provider_lock = state.provider.clone();
3923        let held_provider = provider_lock.write().await;
3924        let providers_before = std::fs::read(dir.path().join("providers.json")).unwrap();
3925        let mut operation = Box::pin(state.put_provider_section(
3926            0,
3927            ProviderConfigs {
3928                openai: Some(bamboo_config::OpenAIConfig {
3929                    model: Some("candidate-model".to_string()),
3930                    ..Default::default()
3931                }),
3932                ..Default::default()
3933            },
3934        ));
3935
3936        assert!(
3937            tokio::time::timeout(Duration::from_millis(500), &mut operation)
3938                .await
3939                .is_err()
3940        );
3941        drop(operation);
3942        assert_eq!(
3943            std::fs::read(dir.path().join("providers.json")).unwrap(),
3944            providers_before,
3945            "cancellation while waiting for publication guards must precede durable commit"
3946        );
3947        drop(held_provider);
3948    }
3949
3950    #[test]
3951    fn cancelled_provider_settings_request_finishes_exact_commit_and_live_publication() {
3952        let _key = bamboo_config::encryption::set_test_encryption_key([0x71; 32]);
3953        let runtime = tokio::runtime::Builder::new_multi_thread()
3954            .worker_threads(2)
3955            .max_blocking_threads(1)
3956            .enable_all()
3957            .build()
3958            .unwrap();
3959        runtime.block_on(async {
3960            let dir = tempfile::tempdir().unwrap();
3961            let state = Arc::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
3962            wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
3963
3964            let (started_tx, started_rx) = tokio::sync::oneshot::channel();
3965            let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
3966            let blocker = tokio::task::spawn_blocking(move || {
3967                let _ = started_tx.send(());
3968                release_rx.recv().unwrap();
3969            });
3970            started_rx.await.unwrap();
3971
3972            let operation_state = state.clone();
3973            let operation = tokio::spawn(async move {
3974                operation_state
3975                    .put_provider_settings(0, |_current, candidate| {
3976                        candidate.provider = "openai".to_string();
3977                        candidate.providers_mut().openai = Some(bamboo_config::OpenAIConfig {
3978                            api_key: "provider-settings-cancel-secret".to_string(),
3979                            model: Some("provider-settings-cancel-model".to_string()),
3980                            ..Default::default()
3981                        });
3982                        Ok((BTreeSet::from(["openai".to_string()]), BTreeSet::new()))
3983                    })
3984                    .await
3985            });
3986
3987            tokio::time::timeout(Duration::from_secs(1), async {
3988                loop {
3989                    if state.config_io_lock.try_lock().is_err() {
3990                        break;
3991                    }
3992                    assert!(!operation.is_finished());
3993                    tokio::task::yield_now().await;
3994                }
3995            })
3996            .await
3997            .expect("provider settings mutation acquires the config IO lock");
3998            operation.abort();
3999            let _ = operation.await;
4000            release_tx.send(()).unwrap();
4001            blocker.await.unwrap();
4002
4003            tokio::time::timeout(Duration::from_secs(5), async {
4004                loop {
4005                    let committed = std::fs::read(dir.path().join("providers.json"))
4006                        .ok()
4007                        .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
4008                        .is_some_and(|value| {
4009                            value["revision"] == 1
4010                                && value["data"]["openai"]["model"]
4011                                    == "provider-settings-cancel-model"
4012                        });
4013                    if committed {
4014                        break;
4015                    }
4016                    tokio::task::yield_now().await;
4017                }
4018            })
4019            .await
4020            .expect("owned provider settings transaction completes after cancellation");
4021
4022            let converged =
4023                tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
4024                    .await
4025                    .expect("owned provider runtime publication completes after cancellation");
4026            drop(converged);
4027            let live = state.config.read().await;
4028            let openai = live.providers().openai.as_ref().unwrap();
4029            assert_eq!(
4030                openai.model.as_deref(),
4031                Some("provider-settings-cancel-model")
4032            );
4033            assert_eq!(openai.api_key, "provider-settings-cancel-secret");
4034            drop(live);
4035            let providers = std::fs::read_to_string(dir.path().join("providers.json")).unwrap();
4036            let credentials = std::fs::read_to_string(dir.path().join("credentials.json")).unwrap();
4037            assert!(!providers.contains("provider-settings-cancel-secret"));
4038            assert!(!credentials.contains("provider-settings-cancel-secret"));
4039        });
4040    }
4041
4042    #[test]
4043    fn cancelled_proxy_update_cannot_leave_durable_state_ahead_of_live_snapshot() {
4044        let runtime = tokio::runtime::Builder::new_multi_thread()
4045            .worker_threads(2)
4046            .max_blocking_threads(1)
4047            .enable_all()
4048            .build()
4049            .unwrap();
4050        runtime.block_on(async {
4051            let dir = tempfile::tempdir().unwrap();
4052            let state = Arc::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
4053            wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
4054
4055            // Occupy Tokio's only blocking worker. The pre-fix implementation
4056            // queued the durable transaction with `spawn_blocking`, so aborting
4057            // the request detached that queued commit from live publication.
4058            let (started_tx, started_rx) = tokio::sync::oneshot::channel();
4059            let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
4060            let blocker = tokio::task::spawn_blocking(move || {
4061                let _ = started_tx.send(());
4062                release_rx.recv().unwrap();
4063            });
4064            started_rx.await.unwrap();
4065
4066            let operation_state = state.clone();
4067            let operation = tokio::spawn(async move {
4068                operation_state
4069                    .update_proxy_auth_credential(
4070                        Some(bamboo_config::ProxyAuth {
4071                            username: "cancel-user".to_string(),
4072                            password: "cancel-secret".to_string(),
4073                        }),
4074                        0,
4075                        ConfigUpdateEffects {
4076                            reload_provider: true,
4077                            reconcile_mcp: true,
4078                        },
4079                    )
4080                    .await
4081            });
4082
4083            // Wait until the owned mutation has acquired config_io_lock and
4084            // queued its blocking transaction. Aborting the caller from this
4085            // exact point reproduced the old detached-commit/live-publication
4086            // split deterministically.
4087            tokio::time::timeout(Duration::from_secs(1), async {
4088                loop {
4089                    if state.config_io_lock.try_lock().is_err() {
4090                        break;
4091                    }
4092                    assert!(!operation.is_finished());
4093                    tokio::task::yield_now().await;
4094                }
4095            })
4096            .await
4097            .expect("proxy mutation acquires the config IO lock");
4098            operation.abort();
4099            let _ = operation.await;
4100            release_tx.send(()).unwrap();
4101            blocker.await.unwrap();
4102
4103            tokio::time::timeout(Duration::from_secs(5), async {
4104                loop {
4105                    let credentials_ready = std::fs::read(dir.path().join("credentials.json"))
4106                        .ok()
4107                        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
4108                        .and_then(|value| value.get("revision").and_then(|value| value.as_u64()))
4109                        == Some(1);
4110                    let config_ready = std::fs::read(dir.path().join("core.json"))
4111                        .ok()
4112                        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
4113                        .and_then(|value| {
4114                            value
4115                                .get("data")
4116                                .and_then(|value| value.get("proxy_auth_credential_ref"))
4117                                .and_then(|value| value.as_str())
4118                                .map(str::to_string)
4119                        })
4120                        .as_deref()
4121                        == Some("proxy.default.auth");
4122                    if credentials_ready && config_ready {
4123                        break;
4124                    }
4125                    tokio::task::yield_now().await;
4126                }
4127            })
4128            .await
4129            .expect("owned durable transaction completes after caller cancellation");
4130
4131            // The owner retains config_io_lock through best-effort provider/MCP
4132            // convergence. Acquiring it proves cancellation did not strand the
4133            // post-commit runtime task either.
4134            let converged =
4135                tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
4136                    .await
4137                    .expect("owned runtime convergence completes after cancellation");
4138            drop(converged);
4139
4140            let live = state.config.read().await;
4141            assert_eq!(
4142                live.proxy_auth_credential_ref
4143                    .as_ref()
4144                    .map(|reference| reference.as_str()),
4145                Some("proxy.default.auth")
4146            );
4147            let auth = live
4148                .proxy_auth
4149                .as_ref()
4150                .expect("durable proxy auth must be published despite cancellation");
4151            assert_eq!(auth.username, "cancel-user");
4152            assert_eq!(auth.password, "cancel-secret");
4153            drop(live);
4154
4155            let root = std::fs::read_to_string(dir.path().join("core.json")).unwrap();
4156            let credentials = std::fs::read_to_string(dir.path().join("credentials.json")).unwrap();
4157            assert!(!root.contains("cancel-secret"));
4158            assert!(!credentials.contains("cancel-secret"));
4159        });
4160    }
4161
4162    #[tokio::test]
4163    async fn missing_or_corrupt_referenced_credentials_reject_candidates_redacted() {
4164        let _key = bamboo_config::encryption::set_test_encryption_key([0x68; 32]);
4165        for corrupt_credentials in [false, true] {
4166            let dir = tempfile::tempdir().unwrap();
4167            let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
4168            let providers = ProviderConfigs {
4169                openai: Some(bamboo_config::OpenAIConfig {
4170                    credential_ref: Some(reference),
4171                    model: Some("candidate".to_string()),
4172                    ..Default::default()
4173                }),
4174                ..Default::default()
4175            };
4176            let provider_store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
4177            provider_store
4178                .commit(0, providers, validate_provider_config)
4179                .unwrap();
4180            if corrupt_credentials {
4181                std::fs::write(dir.path().join("credentials.json"), b"{corrupt-secret").unwrap();
4182            }
4183            let error =
4184                match load_and_prepare_provider_candidate(&provider_store, 0, Config::default())
4185                    .await
4186                {
4187                    Ok(_) => panic!("unavailable credential must reject provider candidate"),
4188                    Err(error) => error,
4189                };
4190            assert_eq!(error.message, "provider credential is unavailable");
4191            assert!(!error
4192                .message
4193                .contains(dir.path().to_string_lossy().as_ref()));
4194
4195            let mut mcp = disabled_mcp_config("credential-lkg");
4196            let TransportConfig::Stdio(stdio) = &mut mcp.servers[0].transport else {
4197                unreachable!()
4198            };
4199            stdio.env_credential_refs.insert(
4200                "TOKEN".to_string(),
4201                bamboo_config::credential_ref("mcp", "credential-lkg", "env_TOKEN")
4202                    .unwrap()
4203                    .as_str()
4204                    .to_string(),
4205            );
4206            let mcp_store = AtomicJsonStore::new(dir.path().join("mcp.json"), 1);
4207            mcp_store.commit(0, mcp, validate_mcp_config).unwrap();
4208            let error = match load_and_validate_mcp_candidate(
4209                &mcp_store,
4210                0,
4211                Config::default(),
4212                false,
4213            )
4214            .await
4215            {
4216                Ok(_) => panic!("unavailable credential must reject MCP candidate"),
4217                Err(error) => error,
4218            };
4219            assert_eq!(error.message, "MCP credential is unavailable");
4220            assert!(!error.message.contains("TOKEN"));
4221            assert!(!error
4222                .message
4223                .contains(dir.path().to_string_lossy().as_ref()));
4224        }
4225    }
4226
4227    #[tokio::test]
4228    async fn typed_provider_put_switches_refs_and_rejects_missing_ref_without_mutation() {
4229        let _key = bamboo_config::encryption::set_test_encryption_key([0x6d; 32]);
4230        let dir = tempfile::tempdir().unwrap();
4231        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4232        let ref_a = bamboo_config::credential_ref("provider", "openai-a", "api_key").unwrap();
4233        let ref_b = bamboo_config::credential_ref("provider", "openai-b", "api_key").unwrap();
4234        let missing = bamboo_config::credential_ref("provider", "missing", "api_key").unwrap();
4235        state
4236            .credential_store
4237            .replace(
4238                ref_a.clone(),
4239                "provider-secret-a",
4240                bamboo_config::CredentialSource::User,
4241                0,
4242            )
4243            .unwrap();
4244        state
4245            .credential_store
4246            .replace(
4247                ref_b.clone(),
4248                "provider-secret-b",
4249                bamboo_config::CredentialSource::User,
4250                1,
4251            )
4252            .unwrap();
4253        {
4254            let mut config = state.config.write().await;
4255            config.provider = "openai".to_string();
4256            *config.providers_mut() = ProviderConfigs {
4257                openai: Some(bamboo_config::OpenAIConfig {
4258                    api_key: "provider-secret-a".to_string(),
4259                    credential_ref: Some(ref_a),
4260                    ..Default::default()
4261                }),
4262                ..Default::default()
4263            };
4264        }
4265
4266        let revision = state
4267            .put_provider_section(
4268                0,
4269                ProviderConfigs {
4270                    openai: Some(bamboo_config::OpenAIConfig {
4271                        credential_ref: Some(ref_b.clone()),
4272                        model: Some("switched".to_string()),
4273                        ..Default::default()
4274                    }),
4275                    ..Default::default()
4276                },
4277            )
4278            .await
4279            .unwrap();
4280        assert_eq!(revision, 1);
4281        let runtime = state.config.read().await;
4282        let openai = runtime.providers().openai.as_ref().unwrap();
4283        assert_eq!(openai.credential_ref.as_ref(), Some(&ref_b));
4284        assert_eq!(openai.api_key, "provider-secret-b");
4285        drop(runtime);
4286        let disk_before = std::fs::read(dir.path().join("providers.json")).unwrap();
4287
4288        assert!(state
4289            .put_provider_section(
4290                1,
4291                ProviderConfigs {
4292                    openai: Some(bamboo_config::OpenAIConfig {
4293                        credential_ref: Some(missing),
4294                        model: Some("must-not-publish".to_string()),
4295                        ..Default::default()
4296                    }),
4297                    ..Default::default()
4298                },
4299            )
4300            .await
4301            .is_err());
4302        assert_eq!(
4303            std::fs::read(dir.path().join("providers.json")).unwrap(),
4304            disk_before
4305        );
4306        let runtime = state.config.read().await;
4307        let openai = runtime.providers().openai.as_ref().unwrap();
4308        assert_eq!(openai.credential_ref.as_ref(), Some(&ref_b));
4309        assert_eq!(openai.api_key, "provider-secret-b");
4310    }
4311
4312    #[tokio::test]
4313    async fn typed_mcp_put_switches_stdio_and_header_refs_atomically() {
4314        let _key = bamboo_config::encryption::set_test_encryption_key([0x6e; 32]);
4315        let dir = tempfile::tempdir().unwrap();
4316        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4317        let refs = [
4318            bamboo_config::credential_ref("mcp", "stdio-a", "env_TOKEN").unwrap(),
4319            bamboo_config::credential_ref("mcp", "header-a", "header_Authorization").unwrap(),
4320            bamboo_config::credential_ref("mcp", "stdio-b", "env_TOKEN").unwrap(),
4321            bamboo_config::credential_ref("mcp", "header-b", "header_Authorization").unwrap(),
4322        ];
4323        for (revision, (reference, value)) in refs
4324            .iter()
4325            .zip(["env-a", "header-a", "env-b", "header-b"])
4326            .enumerate()
4327        {
4328            state
4329                .credential_store
4330                .replace(
4331                    reference.clone(),
4332                    value,
4333                    bamboo_config::CredentialSource::User,
4334                    revision as u64,
4335                )
4336                .unwrap();
4337        }
4338        let make_config = |env_ref: &bamboo_config::CredentialRef,
4339                           header_ref: &bamboo_config::CredentialRef| {
4340            McpConfig {
4341                version: 1,
4342                servers: vec![
4343                    McpServerConfig {
4344                        id: "switch-stdio".to_string(),
4345                        name: None,
4346                        enabled: false,
4347                        transport: TransportConfig::Stdio(StdioConfig {
4348                            command: "unused-disabled-command".to_string(),
4349                            args: vec![],
4350                            cwd: None,
4351                            env: std::collections::HashMap::new(),
4352                            env_encrypted: std::collections::HashMap::new(),
4353                            env_credential_refs: std::collections::HashMap::from([(
4354                                "TOKEN".to_string(),
4355                                env_ref.as_str().to_string(),
4356                            )]),
4357                            startup_timeout_ms: 100,
4358                        }),
4359                        request_timeout_ms: 100,
4360                        healthcheck_interval_ms: 100,
4361                        reconnect: ReconnectConfig::default(),
4362                        allowed_tools: vec![],
4363                        denied_tools: vec![],
4364                    },
4365                    McpServerConfig {
4366                        id: "switch-header".to_string(),
4367                        name: None,
4368                        enabled: false,
4369                        transport: TransportConfig::Sse(bamboo_mcp::SseConfig {
4370                            url: "https://example.test/sse".to_string(),
4371                            headers: vec![bamboo_mcp::HeaderConfig {
4372                                name: "Authorization".to_string(),
4373                                value: String::new(),
4374                                value_encrypted: None,
4375                                credential_ref: Some(header_ref.as_str().to_string()),
4376                            }],
4377                            connect_timeout_ms: 100,
4378                        }),
4379                        request_timeout_ms: 100,
4380                        healthcheck_interval_ms: 100,
4381                        reconnect: ReconnectConfig::default(),
4382                        allowed_tools: vec![],
4383                        denied_tools: vec![],
4384                    },
4385                ],
4386            }
4387        };
4388        let mut current = make_config(&refs[0], &refs[1]);
4389        if let TransportConfig::Stdio(stdio) = &mut current.servers[0].transport {
4390            stdio.env.insert("TOKEN".to_string(), "env-a".to_string());
4391        }
4392        if let TransportConfig::Sse(sse) = &mut current.servers[1].transport {
4393            sse.headers[0].value = "header-a".to_string();
4394        }
4395        state.config.write().await.mcp = current;
4396
4397        assert_eq!(
4398            state
4399                .put_mcp_section(0, make_config(&refs[2], &refs[3]))
4400                .await
4401                .unwrap(),
4402            1
4403        );
4404        let runtime = state.config.read().await;
4405        let TransportConfig::Stdio(stdio) = &runtime.mcp.servers[0].transport else {
4406            panic!("stdio transport")
4407        };
4408        assert_eq!(stdio.env["TOKEN"], "env-b");
4409        let TransportConfig::Sse(sse) = &runtime.mcp.servers[1].transport else {
4410            panic!("sse transport")
4411        };
4412        assert_eq!(sse.headers[0].value, "header-b");
4413        drop(runtime);
4414        let disk_before = std::fs::read(dir.path().join("mcp.json")).unwrap();
4415        let missing_env = bamboo_config::credential_ref("mcp", "missing", "env_TOKEN").unwrap();
4416        let missing_header =
4417            bamboo_config::credential_ref("mcp", "missing", "header_Authorization").unwrap();
4418        assert!(state
4419            .put_mcp_section(1, make_config(&missing_env, &missing_header))
4420            .await
4421            .is_err());
4422        assert_eq!(
4423            std::fs::read(dir.path().join("mcp.json")).unwrap(),
4424            disk_before
4425        );
4426        let runtime = state.config.read().await;
4427        let TransportConfig::Stdio(stdio) = &runtime.mcp.servers[0].transport else {
4428            panic!("stdio transport")
4429        };
4430        assert_eq!(stdio.env["TOKEN"], "env-b");
4431    }
4432
4433    #[tokio::test]
4434    async fn failed_candidate_keeps_existing_provider_registry_and_runtime() {
4435        let dir = tempfile::tempdir().unwrap();
4436        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4437        let working: Arc<dyn LLMProvider> = Arc::new(WorkingProvider);
4438        state
4439            .provider_registry
4440            .insert("working".to_string(), working.clone());
4441        state.provider_registry.set_default("working".to_string());
4442        *state.provider.write().await = working.clone();
4443        state.config.write().await.provider = "openai".to_string();
4444
4445        assert!(state.reload_provider().await.is_err());
4446        assert_eq!(state.provider_registry.default_provider_name(), "working");
4447        assert!(Arc::ptr_eq(
4448            &state.provider_registry.get_default().unwrap(),
4449            &working
4450        ));
4451        let live = state.provider.read().await;
4452        assert!(Arc::ptr_eq(&*live, &working));
4453    }
4454
4455    #[tokio::test]
4456    async fn provider_watcher_retains_lkg_on_invalid_and_recovers_after_repair() {
4457        let _key = bamboo_config::encryption::set_test_encryption_key([0x43; 32]);
4458        let dir = tempfile::tempdir().unwrap();
4459        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4460        let working: Arc<dyn LLMProvider> = Arc::new(WorkingProvider);
4461        state
4462            .provider_registry
4463            .insert("working".to_string(), working.clone());
4464        state.provider_registry.set_default("working".to_string());
4465        *state.provider.write().await = working.clone();
4466        state.config.write().await.provider = "openai".to_string();
4467        wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
4468        let mut feed = state.account_sink.subscribe();
4469        let providers_path = dir.path().join("providers.json");
4470
4471        std::fs::write(&providers_path, b"{broken").unwrap();
4472        tokio::time::timeout(Duration::from_secs(3), async {
4473            loop {
4474                if state
4475                    .config_live_health
4476                    .read()
4477                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4478                    .status
4479                    == SectionStatus::Invalid
4480                {
4481                    break;
4482                }
4483                tokio::time::sleep(Duration::from_millis(20)).await;
4484            }
4485        })
4486        .await
4487        .unwrap();
4488        assert_eq!(
4489            state
4490                .config_live_health
4491                .read()
4492                .unwrap_or_else(|poisoned| poisoned.into_inner())
4493                .revision,
4494            0,
4495            "invalid edits must not advance the LKG revision"
4496        );
4497        {
4498            let health = state
4499                .config_live_health
4500                .read()
4501                .unwrap_or_else(|poisoned| poisoned.into_inner());
4502            assert_eq!(health.status, SectionStatus::Invalid);
4503            assert_eq!(health.source_kind, SectionSourceKind::File);
4504            assert_eq!(health.source_path, providers_path);
4505        }
4506        assert!(Arc::ptr_eq(
4507            &state.provider_registry.get_default().unwrap(),
4508            &working
4509        ));
4510        let invalid = next_config_event(&mut feed, "providers").await;
4511        assert!(matches!(
4512            invalid,
4513            AgentEvent::ConfigInvalid { revision: 0, .. }
4514        ));
4515
4516        let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
4517        let credential_store = bamboo_config::CredentialStore::open(dir.path());
4518        let credential_revision = credential_store.revision().unwrap();
4519        credential_store
4520            .replace(
4521                reference.clone(),
4522                "watcher-test-key",
4523                bamboo_config::CredentialSource::User,
4524                credential_revision,
4525            )
4526            .unwrap();
4527        let providers = ProviderConfigs {
4528            openai: Some(bamboo_config::OpenAIConfig {
4529                credential_ref: Some(reference),
4530                ..Default::default()
4531            }),
4532            ..Default::default()
4533        };
4534        std::fs::write(
4535            &providers_path,
4536            serde_json::to_vec_pretty(&providers).unwrap(),
4537        )
4538        .unwrap();
4539        tokio::time::timeout(Duration::from_secs(3), async {
4540            loop {
4541                let health = state
4542                    .config_live_health
4543                    .read()
4544                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4545                    .clone();
4546                if health.status == SectionStatus::Healthy && health.revision == 1 {
4547                    break;
4548                }
4549                tokio::time::sleep(Duration::from_millis(20)).await;
4550            }
4551        })
4552        .await
4553        .unwrap();
4554        let recovered = next_config_event(&mut feed, "providers").await;
4555        assert!(matches!(
4556            recovered,
4557            AgentEvent::ConfigRecovered { revision: 1, .. }
4558        ));
4559        assert_eq!(state.provider_registry.default_provider_name(), "openai");
4560    }
4561
4562    #[tokio::test]
4563    async fn ordinary_section_watcher_updates_runtime_retains_lkg_and_recovers() {
4564        let _key = bamboo_config::encryption::set_test_encryption_key([0x44; 32]);
4565        let dir = tempfile::tempdir().unwrap();
4566        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4567        wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
4568        let mut feed = state.account_sink.subscribe();
4569        let path = dir.path().join("core.json");
4570        let mut document: serde_json::Value =
4571            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
4572        document["revision"] = serde_json::json!(2);
4573        document["data"]["server"]["port"] = serde_json::json!(9876);
4574        std::fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
4575
4576        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 2).await;
4577        tokio::time::timeout(Duration::from_secs(3), async {
4578            loop {
4579                if state.config.read().await.server.port == 9876 {
4580                    break;
4581                }
4582                tokio::time::sleep(Duration::from_millis(20)).await;
4583            }
4584        })
4585        .await
4586        .unwrap();
4587        assert!(matches!(
4588            next_config_event(&mut feed, "core").await,
4589            AgentEvent::ConfigChanged { revision: 2, .. }
4590        ));
4591
4592        std::fs::write(&path, b"{broken").unwrap();
4593        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Invalid, 2).await;
4594        assert_eq!(state.config.read().await.server.port, 9876);
4595        assert!(matches!(
4596            next_config_event(&mut feed, "core").await,
4597            AgentEvent::ConfigInvalid { revision: 2, .. }
4598        ));
4599
4600        document["revision"] = serde_json::json!(3);
4601        document["data"]["server"]["port"] = serde_json::json!(9877);
4602        std::fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
4603        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 3).await;
4604        tokio::time::timeout(Duration::from_secs(3), async {
4605            loop {
4606                if state.config.read().await.server.port == 9877 {
4607                    break;
4608                }
4609                tokio::time::sleep(Duration::from_millis(20)).await;
4610            }
4611        })
4612        .await
4613        .unwrap();
4614        assert!(matches!(
4615            next_config_event(&mut feed, "core").await,
4616            AgentEvent::ConfigRecovered { revision: 3, .. }
4617        ));
4618
4619        std::fs::remove_file(&path).unwrap();
4620        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Missing, 3).await;
4621        assert_eq!(state.config.read().await.server.port, 9877);
4622        assert!(matches!(
4623            next_config_event(&mut feed, "core").await,
4624            AgentEvent::ConfigInvalid { revision: 3, .. }
4625        ));
4626
4627        document["revision"] = serde_json::json!(4);
4628        document["data"]["server"]["port"] = serde_json::json!(9878);
4629        let swap = dir.path().join("core.json.swap");
4630        std::fs::write(&swap, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
4631        std::fs::rename(&swap, &path).unwrap();
4632        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 4).await;
4633        tokio::time::timeout(Duration::from_secs(3), async {
4634            loop {
4635                if state.config.read().await.server.port == 9878 {
4636                    break;
4637                }
4638                tokio::time::sleep(Duration::from_millis(20)).await;
4639            }
4640        })
4641        .await
4642        .unwrap();
4643        assert!(matches!(
4644            next_config_event(&mut feed, "core").await,
4645            AgentEvent::ConfigRecovered { revision: 4, .. }
4646        ));
4647    }
4648
4649    #[tokio::test]
4650    async fn mcp_watcher_updates_lkg_rejects_invalid_and_recovers_after_atomic_replace() {
4651        let _key = bamboo_config::encryption::set_test_encryption_key([0x45; 32]);
4652        let dir = tempfile::tempdir().unwrap();
4653        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4654        wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
4655        let mut feed = state.account_sink.subscribe();
4656        let path = dir.path().join("mcp.json");
4657
4658        std::fs::write(&path, mcp_document_bytes(2, &disabled_mcp_config("first"))).unwrap();
4659        let first = wait_for_mcp_health(&state, SectionStatus::Healthy, 2).await;
4660        assert_eq!(first.revision, 2);
4661        assert_eq!(state.config.read().await.mcp.servers[0].id, "first");
4662        assert!(matches!(
4663            next_mcp_config_event(&mut feed).await,
4664            AgentEvent::ConfigChanged { revision: 2, .. }
4665        ));
4666
4667        std::fs::write(&path, b"{broken").unwrap();
4668        let invalid = wait_for_mcp_health(&state, SectionStatus::Invalid, 2).await;
4669        assert_eq!(invalid.revision, 2, "invalid candidates cannot advance LKG");
4670        assert_eq!(state.config.read().await.mcp.servers[0].id, "first");
4671        assert!(matches!(
4672            next_mcp_config_event(&mut feed).await,
4673            AgentEvent::ConfigInvalid { revision: 2, .. }
4674        ));
4675
4676        // Model an editor's temp-write + atomic rename, with an immediate
4677        // follow-up write in the same debounce burst. The watcher must settle
4678        // on the final complete document rather than treating the rename gap as
4679        // a reset.
4680        let swap = dir.path().join("mcp.json.swap");
4681        std::fs::write(
4682            &swap,
4683            mcp_document_bytes(3, &disabled_mcp_config("intermediate")),
4684        )
4685        .unwrap();
4686        std::fs::rename(&swap, &path).unwrap();
4687        std::fs::write(
4688            &path,
4689            mcp_document_bytes(3, &disabled_mcp_config("recovered")),
4690        )
4691        .unwrap();
4692        let recovered = wait_for_mcp_health(&state, SectionStatus::Healthy, 3).await;
4693        assert_eq!(recovered.revision, 3, "rename burst should coalesce once");
4694        assert_eq!(state.config.read().await.mcp.servers[0].id, "recovered");
4695        assert!(matches!(
4696            next_mcp_config_event(&mut feed).await,
4697            AgentEvent::ConfigRecovered { revision: 3, .. }
4698        ));
4699
4700        // Reusing the live revision with different content forces the shared
4701        // store to normalize it to revision 4. The normalization write itself
4702        // must be suppressed exactly once rather than triggering a duplicate
4703        // reconcile/event.
4704        std::fs::write(
4705            &path,
4706            mcp_document_bytes(3, &disabled_mcp_config("normalized")),
4707        )
4708        .unwrap();
4709        let normalized = wait_for_mcp_health(&state, SectionStatus::Healthy, 4).await;
4710        assert_eq!(normalized.revision, 4);
4711        assert_eq!(state.config.read().await.mcp.servers[0].id, "normalized");
4712        assert!(matches!(
4713            next_mcp_config_event(&mut feed).await,
4714            AgentEvent::ConfigChanged { revision: 4, .. }
4715        ));
4716        assert!(
4717            tokio::time::timeout(Duration::from_millis(500), feed.recv())
4718                .await
4719                .is_err()
4720        );
4721        let persisted: serde_json::Value =
4722            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
4723        assert_eq!(persisted["revision"], 4);
4724    }
4725
4726    #[tokio::test]
4727    async fn mcp_sidecar_present_at_startup_is_applied_through_runtime_transaction() {
4728        let _key = bamboo_config::encryption::set_test_encryption_key([0x47; 32]);
4729        let dir = tempfile::tempdir().unwrap();
4730        std::fs::write(
4731            dir.path().join("mcp.json"),
4732            mcp_document_bytes(1, &disabled_mcp_config("startup-sidecar")),
4733        )
4734        .unwrap();
4735
4736        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4737        let health = wait_for_mcp_health(&state, SectionStatus::Healthy, 1).await;
4738        assert_eq!(health.revision, 1);
4739        assert_eq!(health.source_kind, SectionSourceKind::File);
4740        assert_eq!(
4741            state.config.read().await.mcp.servers[0].id,
4742            "startup-sidecar"
4743        );
4744    }
4745
4746    #[tokio::test]
4747    async fn mcp_startup_uses_valid_backup_and_reports_degraded_invalid_health() {
4748        let _key = bamboo_config::encryption::set_test_encryption_key([0x48; 32]);
4749        let dir = tempfile::tempdir().unwrap();
4750        let path = dir.path().join("mcp.json");
4751        let store = AtomicJsonStore::new(&path, 1);
4752        store
4753            .commit(0, disabled_mcp_config("backup-lkg"), validate_mcp_config)
4754            .unwrap();
4755        store
4756            .commit(1, disabled_mcp_config("new-primary"), validate_mcp_config)
4757            .unwrap();
4758        std::fs::write(&path, b"{corrupt-primary").unwrap();
4759
4760        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4761        let health = wait_for_mcp_health(&state, SectionStatus::Degraded, 1).await;
4762        assert_eq!(health.revision, 1);
4763        assert_eq!(health.source_kind, SectionSourceKind::Backup);
4764        assert_eq!(health.source_path, path.with_extension("json.bak"));
4765        assert!(health
4766            .last_error
4767            .as_deref()
4768            .unwrap()
4769            .contains("last-known-good backup runtime"));
4770        assert_eq!(state.config.read().await.mcp.servers[0].id, "backup-lkg");
4771
4772        tokio::time::timeout(Duration::from_secs(3), async {
4773            loop {
4774                if state.account_sink.latest_seq() > 0 {
4775                    break;
4776                }
4777                tokio::time::sleep(Duration::from_millis(20)).await;
4778            }
4779        })
4780        .await
4781        .unwrap();
4782        tokio::time::sleep(Duration::from_millis(500)).await;
4783        let events =
4784            bamboo_engine::events::journal::read_since(state.account_sink.events_dir(), 0).unwrap();
4785        assert_eq!(
4786            events
4787                .iter()
4788                .filter(|event| matches!(
4789                    &event.event,
4790                    AgentEvent::ConfigInvalid { section, revision }
4791                        if section == "mcp" && *revision == 1
4792                ))
4793                .count(),
4794            1
4795        );
4796        let stable_health = state
4797            .mcp_config_live_health
4798            .read()
4799            .unwrap_or_else(|poisoned| poisoned.into_inner())
4800            .clone();
4801        assert_eq!(stable_health.status, SectionStatus::Degraded);
4802        assert_eq!(stable_health.source_kind, SectionSourceKind::Backup);
4803        assert_eq!(stable_health.source_path, path.with_extension("json.bak"));
4804    }
4805
4806    #[tokio::test]
4807    async fn mcp_runtime_init_failure_marks_degraded_and_retains_lkg_config() {
4808        let _key = bamboo_config::encryption::set_test_encryption_key([0x46; 32]);
4809        let dir = tempfile::tempdir().unwrap();
4810        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4811        wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
4812        let mut feed = state.account_sink.subscribe();
4813        let path = dir.path().join("mcp.json");
4814
4815        std::fs::write(
4816            &path,
4817            mcp_document_bytes(1, &disabled_mcp_config("last-known-good")),
4818        )
4819        .unwrap();
4820        wait_for_mcp_health(&state, SectionStatus::Healthy, 1).await;
4821        let _ = next_mcp_config_event(&mut feed).await;
4822
4823        let mut failing = disabled_mcp_config("candidate");
4824        failing.servers[0].enabled = true;
4825        if let TransportConfig::Stdio(stdio) = &mut failing.servers[0].transport {
4826            stdio.command = "definitely-not-a-real-mcp-command-597".to_string();
4827        }
4828        std::fs::write(&path, mcp_document_bytes(2, &failing)).unwrap();
4829
4830        let degraded = wait_for_mcp_health(&state, SectionStatus::Degraded, 1).await;
4831        assert_eq!(degraded.revision, 1);
4832        assert!(degraded
4833            .last_error
4834            .as_deref()
4835            .unwrap()
4836            .contains("last-known-good runtime"));
4837        assert_eq!(
4838            state.config.read().await.mcp.servers[0].id,
4839            "last-known-good"
4840        );
4841        assert!(state.mcp_manager.list_servers().is_empty());
4842        assert!(matches!(
4843            next_mcp_config_event(&mut feed).await,
4844            AgentEvent::ConfigInvalid { revision: 1, .. }
4845        ));
4846    }
4847}