# The Elm architecture — reasoning
> **Class:** `reasoning`. This file holds the *why*, the trade-offs, and **every design we
> rejected**. The descriptive shape we actually run is in [`elm-design.md`](elm-design.md),
> which by rule mentions none of this. If you want to know "why not X?" — X is here.
## Why an Elm loop at all
The goal was never architectural purity — it was **testing a GUI without a GUI**. If a
component's whole observable truth is one serializable `Model`, and the only way to change
it is to feed it `Msg`s, then a test can drive it headless, snapshot the `Model`, and
assert — no window, no GPU, no pixel-diffing. That single payoff (the `f3` harness) is the
reason the whole contract exists. Determinism, replay, and robot-testing all fall out of
it. Everything below is in service of keeping that payoff true.
## Rejected: a blanket `impl<T: Elm> Facet for T`
The tidy version is `impl<T: Elm> Facet for T {}` — write `Elm`, get `Facet` free, no macro.
We **rejected** it for two hard reasons:
1. **The orphan rule + migration collision.** During the T1→T4 migration, some components
already had a hand-written `impl Facet`. A blanket impl would collide with every one of
those (conflicting implementations) — the codebase would not compile until *all* 36 were
converted in a single atomic change. The macro let us migrate **one component per commit**,
which is why the migration could run as parallel agent waves at all.
2. **`state_json` richness.** Some components publish a richer introspection json than
`serde(state())`. A blanket impl fixes `state_json` for everyone; the macro's form 3
(`custom_state_json`) lets a component override just that. A blanket impl has no seam for it.
So we pay one macro line per component to buy incremental migration and per-component
overrides. That trade is the macro's entire justification.
## Rejected: `view(&mut self)`
Letting `view` take `&mut self` is more convenient — you can flip a `hovered` flag inline.
We rejected it because it **destroys FC-9**: if `view` can mutate, then "render" and "change
state" are the same act, and the harness can no longer paint a component to inspect it
without also changing it. Pure `view(&self)` returning `Vec<Msg>` keeps paint and mutation
provably separate; the hover flag becomes a `Msg` like everything else.
## Rejected: I/O inside `update`
`update` could just *do* the side effect (spawn the thread, write the file). We made
`update` return `Vec<Effect>` — **data describing** the side work — instead. Reasons: a test
can assert "this Msg should have requested effect X" without X actually happening; and a
component that does no I/O declares `enum Effect {}` (uninhabited, FC-8), which makes
"this component is pure" a fact the *compiler* checks, not a comment. Most facett components
are `Effect {}`.
## Rejected: animation/layout state inside `Model`
Caches — scroll interpolation, animation clocks, memoized layout — are state, so the naive
place is `Model`. We keep them **outside** `Model` in `Cell`/`RefCell` (FC-7). Why: `Model`
is `PartialEq` and gets snapshotted/diffed every test. If a 60fps animation clock lived in
`Model`, every snapshot would differ from the last and no golden file would ever be stable.
`Model` holds *decisions*; caches hold *derived ephemera* and stay out of the serialized truth.
## Rejected: index-based selection
Selecting "row 3" by index is simplest. Rejected (FC-5): the moment the underlying list
reorders or filters, index 3 points at a different thing, and a replayed `Msg` script
selects the wrong item. Selections are **stable ids**; a `Msg` carries the id, and `update`
resolves it against current data. Replay stays correct across reorders.
## How we got here (the road, briefly)
`facett-security` was hand-rolled to the Elm shape first, as the proof it was livable. Once
it was, the macro was written and the migration ran in waves — T1/T2 (simple views), T3/T3-b
(graph/pipeline/git/filedialog/jobview), T4 (the wgpu/3D holdouts: map, geomap, map3d,
graph3d, model3d, video, which need a lighter contract — camera/state `Msg`s with the GPU
render as an Effect-free side surface). The one cross-repo scar worth remembering: jobview's
`toggle_fold` was made private in the migration, which broke nornir's viz consumer; the fix
was to drive it through `Msg::ToggleFold` like every other input — which is exactly the
contract working as intended (there is no back door; everything is a `Msg`).