# Config I/O State Ownership **Rule:** The `AppController` is the single source of truth for the in-memory config (`self.config`) and the only authorized caller of the file I/O primitives in `src/models.py`. ## Why 1. **The controller owns the in-memory state.** If other modules write to `config.toml` directly, the controller's `self.config` silently drifts from disk. Tests can corrupt the user's TOML files; users lose data without warning. 2. **Test isolation breaks.** When `models.save_config(...)` is called from anywhere in `src/`, tests cannot intercept the write without patching the I/O primitive. The test then couples to the file format, not the controller's behavior. 3. **Path resolution can't be enforced.** The controller respects `SLOP_CONFIG` env var at call time. Direct calls to `models.save_config` would only respect it if the path is re-resolved (which it is in `_save_config_to_disk`, but only because someone remembered). ## What is Forbidden in `src/` - `models.load_config(...)` (legacy public function) - `models.save_config(...)` (legacy public function) - `models._load_config_from_disk(...)` (private I/O primitive) - `models._save_config_to_disk(...)` (private I/O primitive) The only allowed call sites are inside `AppController` itself (`load_config()` and `save_config()` methods). ## The Public API ```python # In AppController: def load_config(self) -> Dict[str, Any]: """Re-read the global config.toml from disk and update self.config.""" self.config = models._load_config_from_disk() return self.config def save_config(self) -> None: """Flush self.config to disk.""" models._save_config_to_disk(self.config) ``` Callers (including `gui_2.py`, `commands.py`, etc.) go through the controller: ```python # In App class methods (gui_2.py): __getattr__ delegates to controller self.save_config() # -> controller.save_config() app.save_config() # -> controller.save_config() (via __getattr__) app.load_config() # -> controller.load_config() (via __getattr__) # In AppController: self.save_config() # direct self.load_config() # direct ``` ## Test Patterns Tests should mock the **controller methods**, not the I/O primitives: ```python # CORRECT: route through the controller with patch('src.app_controller.AppController.load_config', return_value={'ai': {...}, 'projects': {...}}): app = App() # controller's load_config returns the mock with patch('src.app_controller.AppController.save_config'): app._save_paths() # controller's save_config is a no-op app.save_config.assert_called_once() # verify the call # WRONG: patch the I/O primitive with patch('src.models._save_config_to_disk'): # bypasses the controller app._save_paths() # still hits the I/O primitive if production bypasses ``` The `mock_app` and `app_instance` fixtures in `tests/conftest.py` follow the correct pattern: they patch `AppController.load_config` and `AppController.save_config` to prevent real I/O and to provide a default config. ## Exceptions The only allowed non-controller call site is the `test_models_no_top_level_tomli_w.py` test, which specifically verifies the lazy-load behavior of the I/O primitive itself (tomli_w import timing). This test is exempt from the audit. ## Enforcement The `scripts/audit_no_models_config_io.py` script enforces this rule. - `python scripts/audit_no_models_config_io.py` — human report - `python scripts/audit_no_models_config_io.py --strict` — exit 1 on violation - `python scripts/audit_no_models_config_io.py --json` — machine output CI should run the `--strict` mode on every PR. ## See Also - `docs/guide_app_controller.md` — the AppController's role - `docs/guide_models.md` — the models module - `conductor/product.md` — "Modular Controller Pattern" principle