Skip to main content

forge_workspace/mcp/settings/
facade.rs

1//! `SettingsFacade` - the seam between the settings MCP tools and the
2//! config machinery. The production impl ([`ProdSettingsFacade`]) reads the
3//! document the settings page draws and routes every write through the
4//! workspace's held-write registry; the mock returns preloaded results so the
5//! tool tests can assert argument handling and error surfacing without a
6//! config.
7
8use std::sync::{Arc, Weak};
9
10use forge_primitives::settings::{
11    SettingsEditOp, SettingsWriteAsk, SettingsWriteChange, SettingsWriteEnding,
12};
13use forge_primitives::{EffectClass, SettingsEditRequest};
14use serde_json::Value;
15use uuid::Uuid;
16
17use crate::SessionSlot;
18use crate::workspace::Workspace;
19
20/// Why a settings read or write did not happen, in the words the tool hands
21/// the caller.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub(crate) enum SettingsError {
24    /// The path is no key this config resolves.
25    UnknownKey(String),
26    /// The key belongs to a project the caller is not in; reads refuse it by
27    /// name rather than returning another project's setting.
28    ForeignProject { key: String, org: String, project: String },
29    /// The key is one of another org's own keys, refused on the same terms.
30    ForeignOrg { key: String, org: String, caller_org: String },
31    /// The key, value or batch was refused before any reader was asked.
32    Wanted(String),
33    /// No client is attached to answer the approval prompt.
34    NoReader,
35    /// The approval prompt closed without an answer.
36    Abandoned,
37    /// The reader approved but the write did not land.
38    Refused(String),
39}
40
41impl SettingsError {
42    /// The sentence a caller reads.
43    pub(crate) fn message(&self) -> String {
44        match self {
45            Self::UnknownKey(key) => format!(
46                "`{key}` is not a key in this config; call settings__index to see the \
47                 catalogue keys and the instances this config declares"
48            ),
49            Self::ForeignProject { key, org, project } => format!(
50                "`{key}` belongs to project {org}/{project}. Ask that project's lead through \
51                 the peer channel - agents__send_message to org \"{org}\", project \
52                 \"{project}\", label \"lead\" - or, if you have no peer channel, report the \
53                 need to your reader instead"
54            ),
55            Self::ForeignOrg { key, org, caller_org } => format!(
56                "`{key}` belongs to org {org}; you are in {caller_org}. Ask one of that org's \
57                 project leads through the peer channel - agents__send_message to org \"{org}\" \
58                 and one of its projects, label \"lead\" - or, if you have no peer channel, \
59                 report the need to your reader instead"
60            ),
61            Self::Wanted(message) => message.clone(),
62            Self::NoReader => "no client is attached to approve a configuration write, so \
63                               nothing was written; the write needs the reader at a client"
64                .to_owned(),
65            Self::Abandoned => {
66                "the approval prompt closed without an answer; nothing was written".to_owned()
67            }
68            Self::Refused(message) => {
69                format!("the write was approved but did not land: {message}")
70            }
71        }
72    }
73}
74
75/// One key's effective value, with what a caller needs to act on it.
76#[derive(Debug, Clone, PartialEq)]
77pub(crate) struct SettingsValue {
78    pub key: String,
79    pub pattern: String,
80    pub effect: EffectClass,
81    pub value: Value,
82}
83
84/// One key and its new value, as `settings__set` hands them through.
85#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
86pub(crate) struct SettingsSetItem {
87    pub key: String,
88    pub value: Value,
89}
90
91/// What a write did, which is what the agent's result says.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub(crate) enum SettingsWriteOutcome {
94    /// The reader approved and the write landed. Each key's effect class is
95    /// carried once, in the order the batch first names one.
96    Applied { effects: Vec<EffectClass> },
97    /// The reader declined; nothing was written.
98    Declined,
99}
100
101/// The settings tools' view of the workspace.
102#[async_trait::async_trait]
103pub(crate) trait SettingsFacade: Send + Sync {
104    /// The compact catalogue: keys, types, state and effect class, no values.
105    async fn index(&self, caller: &SessionSlot) -> Result<String, SettingsError>;
106
107    /// One key's effective value. The caller's own project's keys and the
108    /// global keys read freely; another project's key is refused by name.
109    async fn get(&self, caller: &SessionSlot, key: &str) -> Result<SettingsValue, SettingsError>;
110
111    /// Set one or more keys, applied as one validated save and held for the
112    /// reader's approval.
113    async fn set(
114        &self,
115        caller: &SessionSlot,
116        items: Vec<SettingsSetItem>,
117    ) -> Result<SettingsWriteOutcome, SettingsError>;
118
119    /// Clear one key, held for the reader's approval.
120    async fn unset(
121        &self,
122        caller: &SessionSlot,
123        key: &str,
124    ) -> Result<SettingsWriteOutcome, SettingsError>;
125}
126
127/// Settles the held write as abandoned if the awaiting future is dropped
128/// before the write is settled - the calling session died mid-wait, or its
129/// connection went. The registry's own teardown sweep covers a session that
130/// goes without running this drop; this covers a cancelled call on a live
131/// session. Settling twice is a no-op in the registry.
132struct ResolveOnDrop {
133    workspace: Arc<Workspace>,
134    id: Uuid,
135    caller: SessionSlot,
136}
137
138impl Drop for ResolveOnDrop {
139    fn drop(&mut self) {
140        self.workspace.settle_settings_write(self.id, &self.caller, SettingsWriteEnding::Abandoned);
141    }
142}
143
144pub(crate) struct ProdSettingsFacade {
145    workspace: Weak<Workspace>,
146}
147
148impl ProdSettingsFacade {
149    pub(crate) fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn SettingsFacade> {
150        Arc::new(Self { workspace: Arc::downgrade(workspace) })
151    }
152
153    fn workspace(&self) -> Result<Arc<Workspace>, SettingsError> {
154        self.workspace.upgrade().ok_or_else(|| {
155            SettingsError::Refused("the forge server is no longer running".to_owned())
156        })
157    }
158
159    /// One write, from the catalogue check through the reader's decision to
160    /// the validated writer. **The whole batch is checked before anything is
161    /// asked**: an unknown key, a wrong type, or a batch the loader would
162    /// refuse - a section whose keys are mutually required, say - is refused
163    /// by name with no prompt minted.
164    async fn write(
165        &self,
166        caller: &SessionSlot,
167        items: Vec<SettingsSetItem>,
168        unset: bool,
169    ) -> Result<SettingsWriteOutcome, SettingsError> {
170        if items.is_empty() {
171            return Err(SettingsError::Wanted(
172                "a write needs at least one edit; nothing was written".to_owned(),
173            ));
174        }
175        let ws = self.workspace()?;
176        let config = ws.config_arc();
177        let document = ws.settings_snapshot();
178
179        let mut edits = Vec::with_capacity(items.len());
180        let mut changes = Vec::with_capacity(items.len());
181        let mut effects: Vec<EffectClass> = Vec::new();
182        for item in &items {
183            let target = crate::settings::resolve_target(&config, &document, &item.key)
184                .ok_or_else(|| SettingsError::UnknownKey(item.key.clone()))?;
185            let proposed = if unset { Value::Null } else { item.value.clone() };
186            if unset {
187                if target.edit.required {
188                    return Err(SettingsError::Wanted(format!(
189                        "`{}` is required and cannot be unset",
190                        item.key
191                    )));
192                }
193                if target.current.is_null() {
194                    return Err(SettingsError::Wanted(format!(
195                        "`{}` is already unset; nothing was written",
196                        item.key
197                    )));
198                }
199            } else {
200                if proposed.is_null() {
201                    return Err(SettingsError::Wanted(format!(
202                        "`{}` is cleared with settings__unset, not with a null value",
203                        item.key
204                    )));
205                }
206                crate::settings_write::validate(&proposed, &target.edit, &item.key)
207                    .map_err(|error| SettingsError::Wanted(error.message))?;
208            }
209            edits.push(SettingsEditRequest {
210                area: target.area.clone(),
211                key_path: item.key.clone(),
212                pattern: target.pattern.clone(),
213                op: SettingsEditOp::Set,
214                value: (!unset).then(|| item.value.clone()),
215            });
216            changes.push(SettingsWriteChange {
217                key: item.key.clone(),
218                current: target.current.clone(),
219                proposed,
220                unset,
221            });
222            if !effects.contains(&target.class) {
223                effects.push(target.class);
224            }
225        }
226
227        // A save that changes nothing is not worth a prompt.
228        if changes.iter().all(|change| change.current == change.proposed) {
229            let message = if changes.len() == 1 {
230                format!("`{}` already holds that value; nothing was written", changes[0].key)
231            } else {
232                "every key in this write already holds its value; nothing was written".to_owned()
233            };
234            return Err(SettingsError::Wanted(message));
235        }
236
237        // **The loader validates the batch, not each key.** It is the only
238        // authority on cross-key rules, and its refusal is owed before the
239        // reader is shown a prompt for a save that could not land.
240        let fingerprint = document.fingerprint.clone();
241        ws.validate_settings(&fingerprint, &edits).map_err(|error| match error {
242            crate::workspace::SettingsSaveError::Stale => SettingsError::Wanted(
243                "the configuration changed since it was read; call again to write against \
244                 the current config"
245                    .to_owned(),
246            ),
247            crate::workspace::SettingsSaveError::Invalid { message, .. }
248            | crate::workspace::SettingsSaveError::Refused { message, .. } => {
249                SettingsError::Wanted(message)
250            }
251            crate::workspace::SettingsSaveError::Io(message) => SettingsError::Wanted(format!(
252                "the configuration could not be read to validate this write, so nothing was \
253                 written: {message}"
254            )),
255        })?;
256
257        let ask = SettingsWriteAsk { id: Uuid::new_v4(), edits: changes };
258        let (id, decision, answerable) = ws.register_settings_write(caller, ask);
259        if !answerable {
260            return Err(SettingsError::NoReader);
261        }
262        // The guard outlives the await: dropping the future (a cancelled
263        // tool call, a session that died) settles the registry as abandoned
264        // rather than leaving the prompt to be answered after its asker is
265        // gone.
266        let _guard = ResolveOnDrop { workspace: Arc::clone(&ws), id, caller: caller.clone() };
267        let Ok(approved) = decision.await else {
268            ws.settle_settings_write(id, caller, SettingsWriteEnding::Abandoned);
269            return Err(SettingsError::Abandoned);
270        };
271        if !approved {
272            ws.settle_settings_write(id, caller, SettingsWriteEnding::Declined);
273            return Ok(SettingsWriteOutcome::Declined);
274        }
275        // **The ending is emitted after the write, not after the click.** A
276        // view that did not answer must not read "applied" for an edit the
277        // writer then refused, so the outcome crosses with the ending.
278        match ws.save_settings(&fingerprint, &edits) {
279            Ok(()) => {
280                ws.settle_settings_write(id, caller, SettingsWriteEnding::Applied);
281                Ok(SettingsWriteOutcome::Applied { effects })
282            }
283            Err(error) => {
284                let message = match error {
285                    crate::workspace::SettingsSaveError::Stale => {
286                        "the configuration changed since this write was shown; nothing was \
287                         written. Call again to write against the current config"
288                            .to_owned()
289                    }
290                    other => other.to_string(),
291                };
292                ws.settle_settings_write(
293                    id,
294                    caller,
295                    SettingsWriteEnding::Refused { message: message.clone() },
296                );
297                Err(SettingsError::Refused(message))
298            }
299        }
300    }
301}
302
303#[async_trait::async_trait]
304impl SettingsFacade for ProdSettingsFacade {
305    async fn index(&self, caller: &SessionSlot) -> Result<String, SettingsError> {
306        let ws = self.workspace()?;
307        let document = ws.settings_snapshot();
308        Ok(crate::settings::index_text(&ws.config_arc(), &document, caller.org(), caller.project()))
309    }
310
311    async fn get(&self, caller: &SessionSlot, key: &str) -> Result<SettingsValue, SettingsError> {
312        let ws = self.workspace()?;
313        let config = ws.config_arc();
314        if let Some(owner) = crate::settings::owning_project(&config, key)
315            && (owner.org != caller.org() || owner.name != caller.project())
316        {
317            return Err(SettingsError::ForeignProject {
318                key: key.to_owned(),
319                org: owner.org.clone(),
320                project: owner.name.clone(),
321            });
322        }
323        if let Some(owner_org) = crate::settings::owning_org(&config, key)
324            && owner_org != caller.org()
325        {
326            return Err(SettingsError::ForeignOrg {
327                key: key.to_owned(),
328                org: owner_org.to_owned(),
329                caller_org: caller.org().to_owned(),
330            });
331        }
332        let document = ws.settings_snapshot();
333        let target = crate::settings::resolve_target(&config, &document, key)
334            .ok_or_else(|| SettingsError::UnknownKey(key.to_owned()))?;
335        Ok(SettingsValue {
336            key: target.path,
337            pattern: target.pattern,
338            effect: target.class,
339            value: target.current,
340        })
341    }
342
343    async fn set(
344        &self,
345        caller: &SessionSlot,
346        items: Vec<SettingsSetItem>,
347    ) -> Result<SettingsWriteOutcome, SettingsError> {
348        self.write(caller, items, false).await
349    }
350
351    async fn unset(
352        &self,
353        caller: &SessionSlot,
354        key: &str,
355    ) -> Result<SettingsWriteOutcome, SettingsError> {
356        self.write(caller, vec![SettingsSetItem { key: key.to_owned(), value: Value::Null }], true)
357            .await
358    }
359}
360
361/// Preloaded answers for the tool tests; nothing here touches a config.
362#[cfg(test)]
363#[derive(Default)]
364pub(crate) struct MockSettingsFacade {
365    pub index_text: parking_lot::Mutex<String>,
366    pub values: parking_lot::Mutex<std::collections::HashMap<String, Value>>,
367    pub missing: parking_lot::Mutex<Option<SettingsError>>,
368    pub set_calls: parking_lot::Mutex<Vec<(SessionSlot, Vec<SettingsSetItem>)>>,
369    pub unset_calls: parking_lot::Mutex<Vec<(SessionSlot, String)>>,
370    pub write_result: parking_lot::Mutex<Option<Result<SettingsWriteOutcome, SettingsError>>>,
371}
372
373#[cfg(test)]
374impl MockSettingsFacade {
375    pub(crate) fn new() -> Self {
376        Self::default()
377    }
378
379    pub(crate) fn into_arc(self) -> Arc<dyn SettingsFacade> {
380        Arc::new(self)
381    }
382}
383
384#[cfg(test)]
385#[async_trait::async_trait]
386impl SettingsFacade for MockSettingsFacade {
387    async fn index(&self, _caller: &SessionSlot) -> Result<String, SettingsError> {
388        Ok(self.index_text.lock().clone())
389    }
390
391    async fn get(&self, _caller: &SessionSlot, key: &str) -> Result<SettingsValue, SettingsError> {
392        if let Some(error) = self.missing.lock().clone() {
393            return Err(error);
394        }
395        let value = self
396            .values
397            .lock()
398            .get(key)
399            .cloned()
400            .ok_or_else(|| SettingsError::UnknownKey(key.to_owned()))?;
401        Ok(SettingsValue {
402            key: key.to_owned(),
403            pattern: key.to_owned(),
404            effect: EffectClass::Live,
405            value,
406        })
407    }
408
409    async fn set(
410        &self,
411        caller: &SessionSlot,
412        items: Vec<SettingsSetItem>,
413    ) -> Result<SettingsWriteOutcome, SettingsError> {
414        self.set_calls.lock().push((caller.clone(), items));
415        self.write_result.lock().clone().unwrap_or(Ok(SettingsWriteOutcome::Declined))
416    }
417
418    async fn unset(
419        &self,
420        caller: &SessionSlot,
421        key: &str,
422    ) -> Result<SettingsWriteOutcome, SettingsError> {
423        self.unset_calls.lock().push((caller.clone(), key.to_owned()));
424        self.write_result.lock().clone().unwrap_or(Ok(SettingsWriteOutcome::Declined))
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use std::fs;
431    use std::sync::Arc;
432
433    use serde_json::json;
434
435    use super::*;
436    use crate::SessionSlot;
437
438    const CONFIG: &str = r#"
439[[orgs]]
440name = "Default"
441accounts = ["Stargate"]
442
443[[orgs.projects]]
444name = "forge"
445path = "/tmp/forge"
446max_workers = 2
447
448[[orgs.projects]]
449name = "web"
450path = "/tmp/web"
451
452[[orgs]]
453name = "Other"
454accounts = ["Stargate"]
455
456[[orgs.projects]]
457name = "elsewhere"
458path = "/tmp/elsewhere"
459
460[[accounts]]
461display_name = "Stargate"
462token = "t"
463models = ["claude-sonnet-5"]
464provider = "anthropic"
465"#;
466
467    fn fixture() -> (Arc<Workspace>, tempfile::TempDir) {
468        let dir = tempfile::tempdir().expect("tempdir");
469        let config_dir = crate::config::ensure_forge_data_dir(dir.path()).expect("forge/");
470        fs::write(config_dir.join("forge.toml"), CONFIG).expect("write forge.toml");
471        let workspace = Arc::new(Workspace::new_for_test(dir.path().to_path_buf()).expect("new"));
472        (workspace, dir)
473    }
474
475    fn caller() -> SessionSlot {
476        SessionSlot::lead("Default", "forge")
477    }
478
479    fn item(key: &str, value: serde_json::Value) -> SettingsSetItem {
480        SettingsSetItem { key: key.to_owned(), value }
481    }
482
483    fn facade(ws: &Arc<Workspace>) -> Arc<dyn SettingsFacade> {
484        ProdSettingsFacade::from_arc(ws)
485    }
486
487    async fn wait_for_write(ws: &Arc<Workspace>) -> Uuid {
488        for _ in 0..400 {
489            if let Some(id) = ws.settings_writes.lock().keys().next().copied() {
490                return id;
491            }
492            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
493        }
494        panic!("no configuration write was parked");
495    }
496
497    fn read_config(ws: &Workspace) -> String {
498        fs::read_to_string(ws.config_path()).expect("read forge.toml")
499    }
500
501    /// The index carries the catalogue and no value, read through the tool's
502    /// own facade.
503    #[tokio::test]
504    async fn the_index_carries_the_catalogue_and_no_value() {
505        let (ws, _dir) = fixture();
506
507        let text = facade(&ws).index(&caller()).await.expect("the index reads");
508
509        assert!(text.contains("server.port"), "{text}");
510        assert!(text.contains("Default/forge"), "{text}");
511        assert!(!text.contains("Stargate\", \"t\""), "a value leaked: {text}");
512    }
513
514    /// The caller's own project's keys and the global keys read freely, a
515    /// worker sees the same own-project view its lead does, and another
516    /// project's key is refused by name with the owning project and the
517    /// request path.
518    #[tokio::test]
519    async fn get_reads_the_callers_own_project_and_the_global_keys() {
520        let (ws, _dir) = fixture();
521        let facade = facade(&ws);
522
523        let global = facade.get(&caller(), "server.port").await.expect("a global key reads");
524        assert_eq!(global.pattern, "server.port");
525
526        let own = facade
527            .get(&caller(), "orgs.Default.projects.forge.max_workers")
528            .await
529            .expect("the caller's own project key reads");
530        assert_eq!(own.value, json!(2));
531
532        let worker = SessionSlot::worker("Default", "forge", "settings-worker");
533        let same = facade
534            .get(&worker, "orgs.Default.projects.forge.max_workers")
535            .await
536            .expect("a worker reads its own project");
537        assert_eq!(same, own, "a lead and a worker share the own-project view");
538
539        let error = facade
540            .get(&caller(), "orgs.Default.projects.web.max_workers")
541            .await
542            .expect_err("another project's key is refused");
543        let message = error.message();
544        assert!(message.contains("orgs.Default.projects.web.max_workers"), "{message}");
545        assert!(message.contains("Default/web"), "the owner is named: {message}");
546        assert!(message.contains("agents__send_message"), "the request path is named: {message}");
547        assert!(message.contains("reader"), "the fallback is named: {message}");
548    }
549
550    /// A set parks as a pending ask, the file is unchanged until the reader
551    /// approves, and the approval writes through the validated rename writer.
552    #[tokio::test]
553    async fn an_approved_set_applies_and_a_declined_one_writes_nothing() {
554        let (ws, _dir) = fixture();
555        let _answerer = ws.subscribe_answerer();
556        let facade = facade(&ws);
557        let key = "orgs.Default.projects.forge.max_workers";
558
559        let task = tokio::spawn({
560            let facade = Arc::clone(&facade);
561            async move { facade.set(&caller(), vec![item(key, json!(4))]).await }
562        });
563        let id = wait_for_write(&ws).await;
564
565        assert_eq!(read_config(&ws), CONFIG, "nothing is written while the write waits");
566        let held = ws.pending_asks(&caller());
567        assert!(
568            held.iter().any(|ask| matches!(
569                ask,
570                crate::protocol::PendingAsk::SettingsWrite(held)
571                    if held.id == id
572                        && held.edits.len() == 1
573                        && held.edits[0].current == json!(2)
574                        && held.edits[0].proposed == json!(4)
575            )),
576            "the record read carries the key and both values",
577        );
578
579        assert!(ws.resolve_settings_write(id, &caller(), true));
580        let outcome = task.await.expect("no panic").expect("the write lands");
581        assert_eq!(
582            outcome,
583            SettingsWriteOutcome::Applied {
584                effects: vec![forge_primitives::EffectClass::NextSession],
585            },
586        );
587        assert!(read_config(&ws).contains("max_workers = 4"), "the value landed");
588        ws.reload_config().expect("the written file loads");
589        assert_eq!(ws.config_arc().projects[0].max_workers, Some(4));
590
591        let task = tokio::spawn({
592            let facade = Arc::clone(&facade);
593            async move { facade.set(&caller(), vec![item(key, json!(7))]).await }
594        });
595        let id = wait_for_write(&ws).await;
596        assert!(ws.resolve_settings_write(id, &caller(), false));
597        let outcome = task.await.expect("no panic").expect("a decline is an outcome");
598        assert_eq!(outcome, SettingsWriteOutcome::Declined);
599        assert!(read_config(&ws).contains("max_workers = 4"), "a decline writes nothing");
600    }
601
602    /// A section the config does not have emits no row either: the global
603    /// keys `settings__index` lists are readable before the section exists.
604    #[tokio::test]
605    async fn an_unset_global_section_key_reads_null() {
606        let (ws, _dir) = fixture();
607        let facade = facade(&ws);
608
609        let gotify = facade.get(&caller(), "gotify.url").await.expect("gotify.url reads");
610        assert_eq!(gotify.value, json!(null));
611        assert_eq!(gotify.pattern, "gotify.url");
612
613        let systemone =
614            facade.get(&caller(), "systemone.enabled").await.expect("systemone.enabled reads");
615        assert_eq!(systemone.value, json!(null));
616    }
617
618    /// An optional key the config does not set reads as unset and can be
619    /// enabled through the same held write, which is what the index promises
620    /// by listing it.
621    #[tokio::test]
622    async fn an_unset_optional_key_reads_null_and_can_be_set() {
623        let (ws, _dir) = fixture();
624        let _answerer = ws.subscribe_answerer();
625        let facade = facade(&ws);
626        let key = "orgs.Default.projects.forge.handoff.enabled";
627
628        let read = facade.get(&caller(), key).await.expect("an unset optional key reads");
629        assert_eq!(read.value, json!(null), "the key is addressable before it is set");
630        assert_eq!(read.pattern, "orgs[].projects[].handoff.enabled");
631
632        // The real watcher, not a hand-called reload: the save is one the
633        // server's watch has to see and apply, exactly as the page's save is.
634        let (changed, mut edits) = tokio::sync::mpsc::unbounded_channel();
635        let _watch = crate::env::config_watch::watch(ws.config_path(), changed).expect("the watch");
636        let watching = tokio::spawn({
637            let ws = Arc::clone(&ws);
638            async move {
639                let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
640                loop {
641                    tokio::select! {
642                        Some(()) = edits.recv() => {
643                            let _ = ws.reload_config();
644                        }
645                        () = tokio::time::sleep_until(deadline) => return false,
646                    }
647                    if ws.config_arc().projects[0].handoff.as_ref().and_then(|h| h.enabled)
648                        == Some(true)
649                    {
650                        return true;
651                    }
652                }
653            }
654        });
655
656        let task = tokio::spawn({
657            let facade = Arc::clone(&facade);
658            async move { facade.set(&caller(), vec![item(key, json!(true))]).await }
659        });
660        let id = wait_for_write(&ws).await;
661        assert!(ws.resolve_settings_write(id, &caller(), true));
662        let outcome = task.await.expect("no panic").expect("the write lands");
663        assert!(matches!(outcome, SettingsWriteOutcome::Applied { .. }));
664        assert!(read_config(&ws).contains("enabled = true"), "the key was created");
665        assert!(watching.await.expect("no panic"), "the watcher applied the written config");
666    }
667
668    /// **Every write is held, another project's included.** Reads refuse a
669    /// foreign project's key, but a write is not the read: it parks with the
670    /// key the reader sees, and only their approval lands it.
671    #[tokio::test]
672    async fn a_write_to_another_projects_key_is_held_too() {
673        let (ws, _dir) = fixture();
674        let _answerer = ws.subscribe_answerer();
675        let facade = facade(&ws);
676        let key = "orgs.Default.projects.web.max_workers";
677
678        let task = tokio::spawn({
679            let facade = Arc::clone(&facade);
680            async move { facade.set(&caller(), vec![item(key, json!(5))]).await }
681        });
682        let id = wait_for_write(&ws).await;
683        let held = ws.pending_asks(&caller());
684        assert!(
685            held.iter().any(|ask| matches!(
686                ask,
687                crate::protocol::PendingAsk::SettingsWrite(held)
688                    if held.id == id
689                        && held.edits.iter().any(|edit| edit.key == key && edit.proposed == json!(5))
690            )),
691            "the write parks with the foreign key the reader has to see",
692        );
693
694        assert!(ws.resolve_settings_write(id, &caller(), true));
695        let outcome = task.await.expect("no panic").expect("the write lands");
696        assert!(matches!(outcome, SettingsWriteOutcome::Applied { .. }));
697        assert!(read_config(&ws).contains("max_workers = 5"));
698    }
699
700    /// **An org's own keys belong to that org.** They are not a project's,
701    /// but they shape every project in it, so another org's are refused by
702    /// name with the same request path a project's key gets.
703    #[tokio::test]
704    async fn another_orgs_org_level_key_is_refused_by_name() {
705        let (ws, _dir) = fixture();
706
707        let error = facade(&ws)
708            .get(&caller(), "orgs.Other.browser_profile")
709            .await
710            .expect_err("another org's key is refused");
711
712        let message = error.message();
713        assert!(message.contains("orgs.Other.browser_profile"), "{message}");
714        assert!(message.contains("belongs to org Other"), "{message}");
715        assert!(message.contains("agents__send_message"), "{message}");
716        assert!(message.contains("reader"), "{message}");
717    }
718
719    /// A cancelled call settles the registry as abandoned and says so, so a
720    /// dock elsewhere stands down rather than waiting on a caller that is
721    /// gone. A killed session whose control task outlives its disconnect is
722    /// the teardown sweep's, tested in `settings_ask`.
723    #[tokio::test]
724    async fn a_cancelled_call_settles_the_write_as_abandoned() {
725        let (ws, _dir) = fixture();
726        let _answerer = ws.subscribe_answerer();
727        let mut updates = ws.subscribe_mirror();
728        let facade = facade(&ws);
729        let task = tokio::spawn({
730            let facade = Arc::clone(&facade);
731            async move {
732                facade
733                    .set(&caller(), vec![item("orgs.Default.projects.forge.max_workers", json!(5))])
734                    .await
735            }
736        });
737        let _id = wait_for_write(&ws).await;
738
739        task.abort();
740        let _ = task.await;
741        for _ in 0..200 {
742            if ws.settings_writes.lock().is_empty() {
743                break;
744            }
745            tokio::task::yield_now().await;
746        }
747
748        assert!(ws.settings_writes.lock().is_empty(), "the cancelled call left nothing parked");
749        let mut endings = Vec::new();
750        while let Ok(frame) = updates.try_recv() {
751            if let crate::protocol::SessionUpdate::SettingsWriteResolved { ending, .. } =
752                std::sync::Arc::unwrap_or_clone(frame.update)
753            {
754                endings.push(ending);
755            }
756        }
757        assert_eq!(endings, vec![SettingsWriteEnding::Abandoned]);
758    }
759
760    /// An unknown key and a wrong type are refused naming the key before any
761    /// ask is minted.
762    #[tokio::test]
763    async fn an_unknown_key_or_a_wrong_type_is_refused_before_the_ask() {
764        let (ws, _dir) = fixture();
765        let _answerer = ws.subscribe_answerer();
766        let facade = facade(&ws);
767
768        let unknown = facade
769            .set(&caller(), vec![item("orgs.Default.projects.forge.nope", json!(1))])
770            .await
771            .expect_err("an unknown key is refused");
772        assert!(unknown.message().contains("orgs.Default.projects.forge.nope"));
773        assert!(ws.settings_writes.lock().is_empty(), "no ask was minted");
774
775        let wrong = facade
776            .set(&caller(), vec![item("orgs.Default.projects.forge.max_workers", json!("four"))])
777            .await
778            .expect_err("a wrong type is refused");
779        assert!(wrong.message().contains("max_workers"), "{}", wrong.message());
780        assert!(wrong.message().contains("whole number"), "{}", wrong.message());
781        assert!(ws.settings_writes.lock().is_empty(), "no ask was minted");
782    }
783
784    /// A required key cannot be unset, and an optional one can: the clear
785    /// goes through the same held writer as a set.
786    #[tokio::test]
787    async fn unset_refuses_a_required_key_and_clears_an_optional_one() {
788        let (ws, _dir) = fixture();
789        let _answerer = ws.subscribe_answerer();
790        let facade = facade(&ws);
791
792        let required = facade
793            .unset(&caller(), "orgs.Default.projects.forge.path")
794            .await
795            .expect_err("a required key cannot be unset");
796        assert!(required.message().contains("required"), "{}", required.message());
797
798        let task = tokio::spawn({
799            let facade = Arc::clone(&facade);
800            async move { facade.unset(&caller(), "orgs.Default.projects.forge.max_workers").await }
801        });
802        let id = wait_for_write(&ws).await;
803        assert!(ws.resolve_settings_write(id, &caller(), true));
804        let outcome = task.await.expect("no panic").expect("the clear lands");
805        assert!(matches!(outcome, SettingsWriteOutcome::Applied { .. }));
806        assert!(
807            !read_config(&ws).contains("max_workers"),
808            "the key left the file: {}",
809            read_config(&ws),
810        );
811    }
812
813    /// No client to answer: the write fails closed and nothing is written.
814    #[tokio::test]
815    async fn a_write_with_no_reader_fails_closed() {
816        let (ws, _dir) = fixture();
817
818        let error = facade(&ws)
819            .set(&caller(), vec![item("orgs.Default.projects.forge.max_workers", json!(4))])
820            .await
821            .expect_err("no reader means no write");
822
823        assert_eq!(error, SettingsError::NoReader);
824        assert!(ws.settings_writes.lock().is_empty());
825        assert_eq!(read_config(&ws), CONFIG, "nothing was written");
826    }
827
828    /// A write shown against one file does not land on a file that moved
829    /// underneath it: the approval is refused whole rather than applied to a
830    /// config the reader never saw.
831    #[tokio::test]
832    async fn a_write_shown_against_a_moved_file_is_refused() {
833        let (ws, _dir) = fixture();
834        let _answerer = ws.subscribe_answerer();
835        let facade = facade(&ws);
836
837        let task = tokio::spawn({
838            let facade = Arc::clone(&facade);
839            async move {
840                facade
841                    .set(&caller(), vec![item("orgs.Default.projects.forge.max_workers", json!(4))])
842                    .await
843            }
844        });
845        let id = wait_for_write(&ws).await;
846        fs::write(ws.config_path(), format!("{CONFIG}\n# moved\n")).expect("move the file");
847        assert!(ws.resolve_settings_write(id, &caller(), true));
848
849        let error = task.await.expect("no panic").expect_err("a stale write is refused");
850        assert!(matches!(error, SettingsError::Refused(_)), "{error:?}");
851        assert!(!read_config(&ws).contains("max_workers = 4"), "nothing was written");
852    }
853
854    /// **The acceptance case: a section whose keys are mutually required is
855    /// created in one held, validated write.** `[vault]` needs both its keys,
856    /// so neither can land alone - the pair rides one save, one ask and one
857    /// approval.
858    #[tokio::test]
859    async fn a_vault_pair_creates_the_section_in_one_write() {
860        let (ws, _dir) = fixture();
861        let _answerer = ws.subscribe_answerer();
862        let facade = facade(&ws);
863
864        let task = tokio::spawn({
865            let facade = Arc::clone(&facade);
866            async move {
867                facade
868                    .set(
869                        &caller(),
870                        vec![
871                            item("vault.url", json!("https://vault.test")),
872                            item("vault.email", json!("me@vault.test")),
873                        ],
874                    )
875                    .await
876            }
877        });
878        let id = wait_for_write(&ws).await;
879
880        assert_eq!(read_config(&ws), CONFIG, "nothing is written while the pair waits");
881        let held = ws.pending_asks(&caller());
882        let ask = held
883            .iter()
884            .find_map(|ask| match ask {
885                crate::protocol::PendingAsk::SettingsWrite(held) if held.id == id => {
886                    Some(held.clone())
887                }
888                _ => None,
889            })
890            .expect("the pair parked one write");
891        assert_eq!(ask.edits.len(), 2, "one card carries both keys");
892        assert_eq!(ask.edits[0].key, "vault.url");
893        assert_eq!(ask.edits[0].current, json!(null));
894        assert_eq!(ask.edits[0].proposed, json!("https://vault.test"));
895        assert_eq!(ask.edits[1].key, "vault.email");
896        assert_eq!(ask.edits[1].proposed, json!("me@vault.test"));
897
898        assert!(ws.resolve_settings_write(id, &caller(), true));
899        let outcome = task.await.expect("no panic").expect("the pair lands");
900        assert!(
901            matches!(
902                outcome,
903                SettingsWriteOutcome::Applied { ref effects }
904                    if effects.as_slice() == [forge_primitives::EffectClass::Live]
905            ),
906            "{outcome:?}",
907        );
908
909        let written = read_config(&ws);
910        assert!(written.contains("[vault]"), "the section was created: {written}");
911        assert!(written.contains("url = \"https://vault.test\""), "{written}");
912        assert!(written.contains("email = \"me@vault.test\""), "{written}");
913        ws.reload_config().expect("the written file loads");
914        let vault = ws.config_arc().vault.clone().expect("the section is live");
915        assert_eq!(vault.url, "https://vault.test");
916        assert_eq!(vault.email, "me@vault.test");
917    }
918
919    /// A half pair is refused before the reader is asked: the loader's own
920    /// words name the missing key, and no dock prompt is minted for a write
921    /// that could never land.
922    #[tokio::test]
923    async fn a_partial_pair_is_refused_before_the_ask() {
924        let (ws, _dir) = fixture();
925        let _answerer = ws.subscribe_answerer();
926        let facade = facade(&ws);
927
928        let error = tokio::time::timeout(
929            std::time::Duration::from_secs(10),
930            facade.set(&caller(), vec![item("vault.url", json!("https://vault.test"))]),
931        )
932        .await
933        .expect("the refusal precedes the ask, so nothing waits on an approval")
934        .expect_err("a half [vault] is refused");
935
936        let message = error.message();
937        assert!(message.contains("vault"), "the section is named: {message}");
938        assert!(message.contains("email"), "the missing key is named: {message}");
939        assert!(ws.settings_writes.lock().is_empty(), "no ask was minted");
940        assert_eq!(read_config(&ws), CONFIG, "nothing was written");
941    }
942
943    /// A config that cannot be read to validate the batch refuses it before
944    /// the ask, and the words do not claim a reader approved anything.
945    #[tokio::test]
946    async fn an_unreadable_config_refuses_the_batch_before_the_ask() {
947        let (ws, _dir) = fixture();
948        let _answerer = ws.subscribe_answerer();
949        let facade = facade(&ws);
950        std::fs::remove_file(ws.config_path()).expect("remove forge.toml");
951
952        let error = tokio::time::timeout(
953            std::time::Duration::from_secs(10),
954            facade.set(
955                &caller(),
956                vec![
957                    item("vault.url", json!("https://vault.test")),
958                    item("vault.email", json!("me@vault.test")),
959                ],
960            ),
961        )
962        .await
963        .expect("the refusal precedes the ask, so nothing waits on an approval")
964        .expect_err("an unreadable config refuses the write");
965
966        let message = error.message();
967        assert!(message.contains("could not be read"), "{message}");
968        assert!(!message.contains("approved"), "no reader was asked: {message}");
969        assert!(ws.settings_writes.lock().is_empty(), "no ask was minted");
970    }
971
972    /// **One bad key refuses the whole batch**: the good pair beside it is
973    /// not written, and nothing is asked.
974    #[tokio::test]
975    async fn one_bad_key_refuses_the_whole_batch() {
976        let (ws, _dir) = fixture();
977        let _answerer = ws.subscribe_answerer();
978        let facade = facade(&ws);
979
980        let error = tokio::time::timeout(
981            std::time::Duration::from_secs(10),
982            facade.set(
983                &caller(),
984                vec![
985                    item("vault.url", json!("https://vault.test")),
986                    item("vault.email", json!("me@vault.test")),
987                    item("orgs.Default.projects.forge.max_workers", json!("four")),
988                ],
989            ),
990        )
991        .await
992        .expect("the refusal precedes the ask, so nothing waits on an approval")
993        .expect_err("a wrong-typed member refuses the batch");
994
995        assert!(error.message().contains("max_workers"), "{}", error.message());
996        assert!(ws.settings_writes.lock().is_empty(), "no ask was minted");
997        assert_eq!(read_config(&ws), CONFIG, "the good pair was not written");
998    }
999
1000    /// **A batch naming one key twice is refused before the ask.** The pair
1001    /// that would net back to the value already in the file must not slip
1002    /// past the "changes nothing" guard as a write the reader approves.
1003    #[tokio::test]
1004    async fn a_batch_naming_one_key_twice_is_refused_before_the_ask() {
1005        let (ws, _dir) = fixture();
1006        let _answerer = ws.subscribe_answerer();
1007        let facade = facade(&ws);
1008        let key = "orgs.Default.projects.forge.max_workers";
1009
1010        let error = tokio::time::timeout(
1011            std::time::Duration::from_secs(10),
1012            facade.set(&caller(), vec![item(key, json!(4)), item(key, json!(2))]),
1013        )
1014        .await
1015        .expect("the refusal precedes the ask, so nothing waits on an approval")
1016        .expect_err("a duplicate key is refused");
1017
1018        assert!(error.message().contains("max_workers"), "{}", error.message());
1019        assert!(error.message().contains("twice"), "{}", error.message());
1020        assert!(ws.settings_writes.lock().is_empty(), "no ask was minted");
1021        assert_eq!(read_config(&ws), CONFIG, "nothing was written");
1022    }
1023
1024    /// The hold is the whole batch's: a decline writes neither key.
1025    #[tokio::test]
1026    async fn a_declined_batch_writes_nothing() {
1027        let (ws, _dir) = fixture();
1028        let _answerer = ws.subscribe_answerer();
1029        let facade = facade(&ws);
1030
1031        let task = tokio::spawn({
1032            let facade = Arc::clone(&facade);
1033            async move {
1034                facade
1035                    .set(
1036                        &caller(),
1037                        vec![
1038                            item("vault.url", json!("https://vault.test")),
1039                            item("vault.email", json!("me@vault.test")),
1040                        ],
1041                    )
1042                    .await
1043            }
1044        });
1045        let id = wait_for_write(&ws).await;
1046        assert!(ws.resolve_settings_write(id, &caller(), false));
1047
1048        let outcome = task.await.expect("no panic").expect("a decline is an outcome");
1049        assert_eq!(outcome, SettingsWriteOutcome::Declined);
1050        assert_eq!(read_config(&ws), CONFIG, "a decline writes nothing");
1051        assert!(ws.config_arc().vault.is_none(), "the section stayed absent");
1052    }
1053}