# 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
