# Recipe — Versioned satellite-config files with `mgf.common.versioned`

> **Audience:** consumers shipping satellite config files (hooks,
> schedules, alert specs, profiles, …) that need their own typed
> schema, version field, migration callback, and atomic write.
> **No optional extra** — the bare `pip install mgf-common`
> is enough.

## Why this exists

Every project that grows past a single config file ends up shipping
the same shape multiple times:

- Pydantic model with `extra="forbid"` so unknown keys fail loud.
- A `version: int` (or `schema_version: int`) field at the document
  root so older shapes can be detected.
- A migration callback that upgrades v1 → v2 → … on load.
- An atomic save (temp file + rename) so a crashed write doesn't
  corrupt the file.

`mgf.common.config.BaseAppSettings` covers this for the **single**
top-level settings root with multi-source layering (env / dotenv /
pyproject). It does NOT cover satellite documents — files like
`hooks.yaml`, `schedules.yaml`, `alerts.yaml` that live next to the
main config.

`VersionedFileStore[T]` is that primitive. One generic class. Wire
it once per file.

## At a glance

```python
from pathlib import Path
from pydantic import BaseModel, ConfigDict
from mgf.common.versioned import VersionedFileStore

class Hook(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)
    name: str
    command: str

class HooksFile(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)
    version: int
    hooks: list[Hook] = []

store = VersionedFileStore(
    Path("~/.config/myapp/hooks.yaml").expanduser(),
    schema=HooksFile,
    current_version=1,
)

# Read
if store.exists():
    hooks = store.load()
else:
    hooks = HooksFile(version=1)

# Mutate (the model is frozen — copy with updates)
hooks = hooks.model_copy(update={"hooks": [Hook(name="x", command="y")]})

# Write atomically; the version is auto-stamped to current_version
store.save(hooks)
```

That's the whole pattern. Format auto-detects from the file
extension (`.yaml` / `.yml` → YAML, `.json` → JSON).

## What you get

| Behaviour | How |
|---|---|
| Unknown keys rejected | `schema` MUST set `extra="forbid"`; verified at construction |
| Missing version field rejected | Verified at load time, with a help note |
| Wrong on-disk version | Routed through `migrate=` if provided; else `AppConfigError` |
| Validation errors | Each pydantic error becomes a PEP 678 note on the raised `AppConfigError` |
| Atomic write | Temp file in same directory, fsync, rename, mode 0o600 |
| Crash during write | Original file untouched; no leftover temp files |
| Stale version on save | Auto-overwritten to `current_version` |

## Migrations

The migration callback is called at load time when the on-disk
`version` differs from `current_version`. It receives the raw parsed
dict and the on-disk version; it returns the migrated raw dict
ready for schema validation.

### Single-step migration (v1 → v2)

```python
def migrate(raw: Mapping[str, Any], from_version: int) -> Mapping[str, Any]:
    if from_version == 1:
        # v1 had `cmd`; v2 renames to `command`.
        return {
            **raw,
            "hooks": [
                {"name": h["name"], "command": h["cmd"]}
                for h in raw["hooks"]
            ],
        }
    return raw

store = VersionedFileStore(
    path,
    schema=HooksFile,
    current_version=2,
    migrate=migrate,
)
```

### Multi-step migration (v1 → v2 → v3)

The store calls the callback **once** with the on-disk version. Chain
the steps inside the callback:

```python
def migrate(raw: Mapping[str, Any], from_version: int) -> Mapping[str, Any]:
    data = dict(raw)
    if from_version <= 1:
        # v1 → v2: rename cmd → command
        data = {
            **data,
            "hooks": [
                {"name": h["name"], "command": h["cmd"]}
                for h in data["hooks"]
            ],
        }
    if from_version <= 2:
        # v2 → v3: add a default `enabled` flag
        data = {
            **data,
            "hooks": [{**h, "enabled": True} for h in data["hooks"]],
        }
    return data
```

Each `if from_version <= N:` block upgrades from any version below
`N+1` to `N+1`. The cumulative effect upgrades through the chain.

## Choosing YAML vs JSON

| Format | When to pick | Notes |
|---|---|---|
| YAML | Human-edited files (hooks, profiles, alert specs) | Comments allowed, anchors / refs supported, indentation-sensitive |
| JSON | Machine-written / read files (cache state, snapshots, fixtures) | Stdlib, deterministic, no comment support |

If your file extension is non-standard (e.g., `.cfg`, `.dat`), pass
`file_format="yaml"` or `file_format="json"` explicitly.

## Choosing the version-field name

Default: `"version"`. The reference consumer also uses
`"schema_version"` for files where `version` would collide with a
domain field. Pass `version_field="schema_version"` (or any other
name) at construction:

```python
class CaseFile(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)
    schema_version: int
    case_id: str
    # ...

store = VersionedFileStore(
    path,
    schema=CaseFile,
    current_version=2,
    version_field="schema_version",
)
```

The store enforces that the schema actually has the named field
(typed as `int`) at construction time — typo'd field names fail
loudly during wiring, not at load time.

## Errors

Every failure path raises `mgf.common.exceptions.AppConfigError` with
PEP 678 notes pointing at the offending file + key. Catch the type
once at the boundary:

```python
from mgf.common.exceptions import AppConfigError

try:
    hooks = store.load()
except AppConfigError as exc:
    log.error("hooks file unusable: %s", exc)
    for note in getattr(exc, "__notes__", []):
        log.error("  %s", note)
    sys.exit(1)
```

The notes are operator-grade: they name the path, the offending
field, and the suggested fix.

## Concurrency

`VersionedFileStore` is **thread-safe** (no shared mutable state).
Multiple threads in the same process can hold references to the same
store and call `load()` / `save()` concurrently — file I/O is
serialised at the OS level, and the temp-file-rename atomicity
ensures readers never see a half-written document.

It is **not process-safe**: concurrent `save()` calls from different
processes targeting the same path can race at the rename step. If
you need cross-process coordination, layer your own file lock on top
(e.g., `fcntl.flock` or the `filelock` library).

## When to use this vs. `BaseAppSettings`

| Use `BaseAppSettings` for | Use `VersionedFileStore[T]` for |
|---|---|
| The **one** top-level settings root your app loads at startup | Satellite config files (hooks, schedules, alerts) |
| Multi-source layering (env / dotenv / pyproject) | Single source of truth (the file IS the config) |
| Settings that change between deployments | Documents that change over the app's lifetime |
| Process-global state | Many-per-app, document-per-instance |

In a typical app you'll have **one** `BaseAppSettings` subclass and
**multiple** `VersionedFileStore[T]` instances (one per satellite
file).

## See also

- [`docs/public/03_config.md`](../03_config.md) — `BaseAppSettings`
  for the top-level settings root.
- [`docs/public/04_settings.md`](../04_settings.md) — `MgfSettings`
  composition pattern.
- [`docs/standards/CONFIGURATION.md`](../../standards/CONFIGURATION.md)
  — the cross-project configuration standard, including
  schema-versioning + migration discipline.
