Gunnar

nordisk/facett

.nornir/facet-reasoning.md

# The Facet contract — reasoning

> **Class:** `reasoning`. The *why* behind the `Facet` contract, the trade-offs, and the
> alternatives we rejected. The descriptive shape is in [`facet-design.md`](facet-design.md).
> This also holds the **testability contract** (FC-1 … FC-9) that a component must satisfy to
> be robot-drivable — the deeper precondition the nornir completeness gate enforces.

## Why `state_json` is mandatory (not optional)

korp and nornir are **robot-tested headless** — launched with `$APP_STATE` set, driven, and
asserted against the JSON. A component with no `state_json` is invisible to that harness. So
the trait *requires* it: the introspection contract is part of being a facett component, not
an afterthought. Every visible list/status/count must be in `state_json`, or a test cannot
see it. This is the single non-negotiable obligation, and everything else is defaulted around
it.

## Why the capability surface is defaulted (not required)

`caps` / `scale` / `copy` / `paste` / `kind` all have do-nothing defaults. The alternative —
make every component implement all ten — would have forced a large, mostly-boilerplate impl on
every port and blocked incremental migration. Defaulting them to the opt-out floor means the
three required methods stay the whole *obligation*, a component opts into exactly the
capabilities it has, and nothing panics when a facet declares nothing. The deck reading
`caps()` (rather than probing) is what lets it route only the shortcuts a facet honors.

## Why the deck owns the OS clipboard (components stay pure)

`copy`/`cut`/`paste` return/accept **text**; the single OS-touching `clipboard::put` lives in
the deck. Had each component touched the OS clipboard directly, none of them would be
unit-testable without a display server, and copy semantics would drift per component. Keeping
the components pure (text in, text out) and centralizing the side effect is what makes
`copy()` assertable in a plain `#[test]`.

## Why the interaction primitives are reducers, not egui widgets (nor Facets)

Drag-and-drop, time pan/zoom, and an action outbox recur across facets (kanban, gantt, CFD,
calendar, timeline). Three shapes were possible: (a) an egui widget each facet drops in, (b)
copy-pasted per-facet logic, or (c) an engine-agnostic reducer each facet *uses*. We chose (c)
— `dragdrop`, `time_axis`, `action_bus` as pure serde state machines with one
`update(Msg) -> Effect` path — for the same reason the deck owns the clipboard: **an egui
widget hides observable state in `ui.memory` (FC-1) and can't be driven without a display
(FC-2/FC-6).** A reducer's transitions are a plain `#[test]` property (FC-7); a drag is
`Start → HoverZone → Drop` `Msg`s asserted against `state_json`, no pointer simulation. This
is exactly why they sit beside `nav::Navigable` and `scroll_engine::SmoothScroll` rather than
inside the facets: the shared, tested logic lives once, and the per-facet `impl Facet` is a
thin painter over it.

They deliberately do **not** `impl Facet`. A primitive isn't a tab — it's a *part* of a facet
(a gantt is one `TimeAxis` + one `DragDrop`, not three tabs). Keeping them trait-free lets one
facet compose several, and keeps the deck's `{T : impl Facet}` enumeration (the completeness
gate) counting *components*, not their internal machinery.

**Why the effect is returned as data, and why the bus is deterministic.** `update` hands back
`DragEffect::Moved` / `TimeEffect::WindowChanged` rather than performing the transition itself,
so the host stays the single place a move is committed (and can reject it) — the FC-8 "side
work as data" rule. `action_bus` is the common carrier those effects flow through, and it
stamps every `BusAction` with a **monotonic `seq` that never resets across drains**. That
ordering is the whole point: without it a headless test could assert *what* a facet emitted
but not *in what order* two gestures fired — and cross-facet effects (a scrub that scrolls
linked facets, a drop that opens a card) are order-sensitive. A deterministic outbox makes the
emitted sequence an assertable value (FC-7), which an egui event queue never is.

## The testability contract (FC-1 … FC-9)

A component is testable and agent-drivable exactly to the degree that an external process can
(a) **write** its inputs, (b) **read** its outputs, and (c) **observe** it semantically and
visually — without simulating a human, a mouse, or a screen. egui is immediate-mode and will
happily hide state inside widget memory; this contract forbids that. Keywords per RFC 2119.

| id | invariant | the smell it forbids |
|---|---|---|
| **FC-1** | no hidden state — all observable state in the `Model` | `ui.memory_mut` / `ctx.data_mut` holding observable state |
| **FC-2** | writable input surface — every input expressible as a `Msg` | an input reachable only by raw pointer/keyboard simulation |
| **FC-3** | readable output surface — `Model` fully serializable, `state(&self)` exposed | a field that never round-trips through serde |
| **FC-4** | semantic exposure — every interactive/value element emits an AccessKit node | a custom-painted widget invisible to the a11y tree |
| **FC-5** | stable identity — ids keyed off a stable domain id, never list index / time / RNG | `Id::new(index)`; duplicate labels within a Surface |
| **FC-6** | picturable in isolation — renders headless through the offscreen path | a render that needs a presented swapchain |
| **FC-7** | determinism — same `Model` + `Msg`s → same tree + same frame | ambient `Instant::now()` / `rand` in `view`/`update` |
| **FC-8** | explicit quiescence — idle/pending is observable, no wall-clock sleeps | `thread::sleep` to "wait for the UI" |
| **FC-9** | pure view — `view(&Model, &mut Ui)` emits `Msg`s, holds no state | a `self.x = …` inside `view` |

**Anti-patterns to flag** (each maps to the violated FC): observable state in egui memory →
FC-1; a painted interactive element with no `WidgetInfo`/AccessKit node → FC-4; an id derived
from loop index / timestamp → FC-5; ambient time or RNG in `view`/`update` → FC-7; blocking
I/O or `.await` inside `update` → FC-8; a render path requiring a presented window → FC-6.

## Two-speed adoption (how we got here)

The `Facet` trait as it stands is `fn ui(&mut self, ui: &mut Ui)` — it *mutates* `self`. That
is **not** the `Model`/`Msg`/`Effect`/pure-`view` split FC-2/FC-9 demand. So adoption ran two
speeds: FC-1, 3, 4, 5, 6, 7, 8 were enforceable on the existing trait immediately (the first
compliance pass), while FC-2 + FC-9 (the full Elm shape) landed as a deliberate architectural
migration across ~16 components, wave by wave, not as a silent relaxation. The bridge is the
[`elm`](elm-reasoning.md) macro `impl_facet_via_elm!`, which writes the mutating `Facet::ui`
from a pure `Elm` `view`+`update` — so a component can be internally pure (FC-9) while the
host still sees the simple `ui(&mut self)` surface. That macro seam is why the two speeds could
coexist in one compiling codebase.

## Relationship to the nornir completeness gate

This contract is the **per-component precondition**; the nornir test-matrix completeness gate
is the **library-wide enforcer**. The gate enumerates components as `{T : impl Facet for T}` ∩
`registry()`, then reads FC-3 (`state(&self)`) and FC-4 (AccessKit labels) to assert real data
and run the sentinel-ban; "no silent gaps" = `registry_covers_all_components`. Thin/fat parity
needs FC-3's serializable state so both sides compare equal on the same data; FC-7 determinism
is what makes the gate's screenshot-proof reproducible.