Gunnar

nordisk/facett

.nornir/fc2-fc9-rollout-schedule-history.md

# facett — FC-2 / FC-9 Compliance Matrix & Planned Rollout Schedule

> **Provenance:** authored 2026-07-03 (audit by agent, commissioned per
> `component-contract.md` §Reconciliation, which states *"FC-2 + FC-9 (full Elm `Msg`
> + pure view) is an architectural decision across ~16 components … FC-2/FC-9 land on
> a **planned schedule**, not as a silent relaxation of the others."* This document
> **is** that planned schedule. It is additive: it modifies **no** component source and
> **no** requirement in `component-contract.md`. Where the two disagree, the contract
> governs; this doc only sequences the work.

## 0. Scope, method, honesty note

**Method.** Static source audit only — no `cargo` build, no `egui_kittest` run (per the
audit brief). Verdicts that the contract says must be *verified at runtime* (FC-4
AccessKit retrieval, FC-5 id stability under reorder, FC-6 offscreen render, FC-7
frame-equality) are therefore marked **provisional (🟠)** wherever a static read cannot
prove them, with the reason stated. FC-2 and FC-9 — the subject of this schedule — are
**structurally** decidable from source (does a `Msg` enum exist? is `view` a
`&self -> Vec<Msg>` function?) and are graded firmly.

**Population.** The contract's "~16 components" is now an undercount. The workspace
currently holds **32 crates that `impl Facet` at the component level** (≈40 distinct
`impl Facet` blocks — `facett-plotters` alone ships 5: boxplot/candlestick/heatmap/
treemap/violin; `facett-graphview` ships 2; `facett-graph` and `facett-table` ship 2
each). Excluded as **non-components** (no `impl Facet` at lib level): `facett-core`
(the trait + `FacetDeck`), `facett-app` (shell), `facett-demo` (demo binary),
`facett-arrow` (columnar adapter), `facett-osm` (tile/data provider), `facett-trace`
(diagnostics), `facett-menu` (overlay widget — `impl Facet` only in an example),
`facett-form` (widget builder), `facett-map3d-flight` (a **support** crate for
`facett-map3d`; its `enum Msg` at `facett-map3d-flight/src/tiles.rs:43` is an internal
tile-streaming message, **not** a component input surface).

## 1. The three-tier reality (the headline)

Today's `Facet` trait is `fn ui(&mut self, ui: &mut Ui)` — it mutates `self` and paints
in one pass. Against that baseline, every component falls into exactly one of three tiers
for FC-2/FC-9:

| Tier | Meaning | Members |
|---|---|---|
| **T0 — reference (✅/✅)** | Genuine Elm split already in place: pure `render(&self)->Vec<Msg>`, `update(&mut self, Msg)->Vec<Effect>`, `state(&self)->&Model`. | **facett-security** (1) |
| **T1 — partial (🟠/🟠)** | A real `Msg` enum + `update(&mut self, Msg)` exist, **but** `update` is called *inline* from the `&mut self` `ui()` pass, so the view is not pure; `update` returns `bool` (dirty), not `Vec<Effect>`; no `state()` accessor; model not fully serde. Half the pattern is done. | **facett-helix, facett-plandag, facett-timeline** (3) |
| **T2 — missing (❌/❌)** | No `Msg` enum. `ui()` mutates `self` directly on click/drag. FC-2 and FC-9 both absent; the input vocabulary must be invented from scratch. | the other **28** components |

**facett-security is the worked reference.** It hand-rolls exactly the bridge the whole
library needs (`facett-security/src/lib.rs:632-639`):

```rust
fn ui(&mut self, ui: &mut egui::Ui) {
    // FC-9: the view is pure (paints + emits Msgs); FC-2: mutation only via `update`.
    let msgs = self.render(ui);          // render(&self, ui) -> Vec<Msg>   [lib.rs:475]
    for m in msgs { self.update(m); }    // update(&mut self, Msg) -> Vec<Effect> [lib.rs:423]
}
```

with `state(&self) -> &SecurityState` (`lib.rs:397`) and an intentionally **uninhabited**
`enum Effect {}` (`lib.rs:372`) — the honest statement that this component does no I/O.
Lifting this bridge into `facett-core` is the shared-infra prerequisite (§4).

## 2. Compliance matrix (component × FC-1 … FC-9)

Legend: ✅ met · 🟠 partial / provisional-pending-runtime-verify · ❌ absent.
Columns FC-2 and FC-9 are graded firmly; the rest carry the static-audit caveat from §0.

**Cross-cutting findings (apply to every row unless noted):**
- **FC-1 ✅ all** — no component stores *observable* state in egui memory. The only
  `ctx.data_mut`/`ui.memory` usage in the tree is theme/keymap/effects infra inside
  `facett-core` (`theme.rs`, `look/feel.rs`, `look/keymap.rs`, `look/policy.rs`) and one
  read-only focus probe (`facett-map3d/src/lib.rs:1439`) — all ephemeral, non-observable.
- **FC-7 ✅ all** — zero `Instant::now()` / `SystemTime::now()` / `rand::*` in any
  component `src`. Animated components inject a clock (e.g. `facett-helix` `clock_s`,
  falling back to `ui.input(|i| i.time)` only when unset — `helix/src/lib.rs:404-409`).
- **FC-8 ✅ all** — **no component performs async or background I/O** (grep for
  `thread::spawn|tokio|.await|std::fs|reqwest|channel` over component `src` is empty).
  Data is host-fed; every component is idle-by-construction. `security`'s empty `Effect`
  enum is the correct expression; the others simply have no effect surface to declare yet.
- **FC-3 🟠 all except security** — every component ships `state_json()` (the *current*
  contract mechanism, `Facet::state_json`), but the **strict** FC-3 (a `Model` deriving
  `Serialize+Deserialize+Clone+PartialEq` **and** a `state(&self)->&Model` accessor) is
  met only by `facett-security`. Several carry `Serialize` on their model already (card,
  console, filedialog, git, graphview, helix, log, stat, tabbar, table) — a short hop.
- **FC-4 / FC-5 🟠 provisional** — the contract mandates `egui_kittest` retrieval (FC-4)
  and reorder-stability (FC-5) to *verify*; a static audit cannot. Custom-painter-heavy
  components (timeline, helix, plandag, plot, plotters, matrix, syschart, the map/graph
  families) are the FC-4 risk: painted value-glyphs must emit AccessKit nodes. Marked 🟠
  pending a kittest pass; `security` is ✅ (emits `WidgetInfo` + is tested).
- **FC-6 ✅ 2D / 🟠 wgpu** — pure-egui-painter components render offscreen today (✅).
  Components with a wgpu/3D/custom-render path — **geomap, graph3d, graphview, helix,
  map, map3d, model3d, video** — are 🟠: they *should* work through the software-wgpu
  path `egui_kittest` uses, but that is unproven here.

| component | FC-1 | **FC-2** | FC-3 | FC-4 | FC-5 | FC-6 | FC-7 | FC-8 | **FC-9** | tier |
|---|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
| facett-security | ✅ | **✅** | ✅ | ✅ | 🟠 | ✅ | ✅ | ✅ | **✅** | T0 |
| facett-helix | ✅ | **🟠** | 🟠 | 🟠 | 🟠 | 🟠 | ✅ | ✅ | **🟠** | T1 |
| facett-plandag | ✅ | **🟠** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **🟠** | T1 |
| facett-timeline | ✅ | **🟠** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **🟠** | T1 |
| facett-pop | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T2 |
| facett-stat | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T2 |
| facett-card | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T2 |
| facett-log | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T2 |
| facett-docview | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T2 |
| facett-matrix | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T2 |
| facett-plot | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T2 |
| facett-plotters (×5) | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T2 |
| facett-syschart | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T2 |
| facett-tabbar | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T2 |
| facett-console | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T3 |
| facett-table | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T3 |
| facett-grid | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T3 |
| facett-warehousedeck | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T3 |
| facett-icebergview | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T3 |
| facett-jobview | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T3 |
| facett-filedialog | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T3 |
| facett-pipeline | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T3 |
| facett-graph | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T3 |
| facett-graphpan | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T3 |
| facett-graphview | ✅ | **❌** | 🟠 | 🟠 | 🟠 | 🟠 | ✅ | ✅ | **❌** | T3 |
| facett-git | ✅ | **❌** | 🟠 | 🟠 | 🟠 | ✅ | ✅ | ✅ | **❌** | T3 |
| facett-map | ✅ | **❌** | 🟠 | 🟠 | 🟠 | 🟠 | ✅ | ✅ | **❌** | T4 |
| facett-geomap | ✅ | **❌** | 🟠 | 🟠 | 🟠 | 🟠 | ✅ | ✅ | **❌** | T4 |
| facett-map3d | ✅ | **❌** | 🟠 | 🟠 | 🟠 | 🟠 | ✅ | ✅ | **❌** | T4 |
| facett-graph3d | ✅ | **❌** | 🟠 | 🟠 | 🟠 | 🟠 | ✅ | ✅ | **❌** | T4 |
| facett-model3d | ✅ | **❌** | 🟠 | 🟠 | 🟠 | 🟠 | ✅ | ✅ | **❌** | T4 |
| facett-video | ✅ | **❌** | 🟠 | 🟠 | 🟠 | 🟠 | ✅ | ✅ | **❌** | T4 |

### Count summary

| | ✅ full | 🟠 partial | ❌ missing |
|---|:--:|:--:|:--:|
| **FC-2 (writable Msg surface)** | 1 (security) | 3 (helix, plandag, timeline) | 28 |
| **FC-9 (pure view)** | 1 (security) | 3 (helix, plandag, timeline) | 28 |

FC-2 and FC-9 track each other exactly — unsurprising, since the same architectural
move (extract `view(&self)->Vec<Msg>`, route all mutation through `update`) satisfies
both at once. Any component that lands one lands the other.

## 3. Per-tier delta (what specifically must change)

**T0 — facett-security — done.** Optional polish: rename private `render` → `view` and
make it (or a thin wrapper) `pub` so it matches the canonical signature in
`component-contract.md` §3 verbatim, and so callers can drive it headlessly without going
through `Facet::ui`. Zero behavioral change.

**T1 — helix, plandag, timeline — the *extract-pure-view* delta (small–medium each):**
- The `Msg` enum + `update(&mut self, Msg)` already exist
  (`helix:54/191`, `plandag:62/112`, `timeline:91/140`). **FC-2 is 80% there.**
- What's missing: today `ui()` calls `self.update(Msg::…)` *inline* while painting
  (e.g. `plandag/src/lib.rs:328,336,348-355`; `helix:375-396`; `timeline:328-333`), so the
  render pass mutates `self` → **not** a pure view.
- **Delta:** split `ui()` into `view(&self, ui)->Vec<Msg>` (collect, don't apply) + the
  security-style bridge. Change `update`'s return from `bool` to `Vec<Effect>` (with an
  empty `enum Effect {}` — all three are sync, FC-8-trivial). Add `state(&self)->&Model`
  and derive full serde on the view struct (`HelixView`, `PlanDagView`, `TimelineView`).
- **Watch-out (timeline, helix):** interactions come from *painted canvas* hit-tests, not
  just widgets. Every drag/click on the canvas must become a `Msg` (e.g.
  `Msg::PanTo`, `Msg::PickBar(id)`), and the picked id must key off a **stable domain id**
  (FC-5), not a loop index. This is the medium part.

**T2 — display components (pop, stat, card, log, docview, matrix, plot, plotters×5,
syschart, tabbar) — the *mechanical-wrap* delta (small each):** tiny input surfaces
(mostly hover/select/scale). Introduce a small `Msg` enum for the handful of inputs, move
the field writes into `update`, collect from `view`. Once the core bridge (§4) exists,
each is a near-boilerplate change. `tabbar`'s `Msg` is essentially `SelectTab(id)`.

**T3 — interactive data components (console, table, grid, warehousedeck, icebergview,
jobview, filedialog, pipeline, graph, graphpan, graphview, git) — the *rich-Msg* delta
(medium each):** real interaction — row/cell select, sort, column resize, expand/collapse,
navigate, filter. The `Msg` enum is substantial and every mutation path must be routed
through it (FC-2's "no input reachable only by pointer simulation"). FC-5 matters here:
sort/reorder must preserve element ids off stable domain keys. `graphpan`/`graphview`
compose an inner `GraphView` (note `graphpan`'s existing `view()` at `lib.rs:564` is a
**getter** returning `&GraphView`, *not* an FC-9 view — do not confuse the two).

**T4 — wgpu / 3D / video (map, geomap, map3d, graph3d, model3d, video) — the *big-lift*
delta (large each):** continuous camera input (pan/zoom/orbit) forces a design decision on
`Msg` granularity (discrete `Msg::Orbit(delta)` per frame vs. a camera-state `Msg`); the
custom render path must be proven FC-6-offscreen; and FC-4 for painted/3D value elements
is genuinely hard. These are the last to convert and warrant their own mini-design each.
(None currently do async I/O, so at least FC-8 stays trivial.)

## 4. Shared infrastructure — **land this first**

Before converting any component, lift `facett-security`'s hand-rolled bridge into
`facett-core` as the canonical, reusable scaffold. Recommended shape (illustrative):

```rust
// facett-core: the Elm contract, with a blanket Facet bridge.
pub trait Elm {
    type Model: Serialize + DeserializeOwned + Clone + PartialEq;
    type Msg:   Clone + std::fmt::Debug;
    type Effect;
    fn state(&self) -> &Self::Model;                         // FC-3
    fn update(&mut self, msg: Self::Msg) -> Vec<Self::Effect>; // FC-2, FC-8
    fn view(&self, ui: &mut egui::Ui) -> Vec<Self::Msg>;      // FC-9, FC-4
    fn title(&self) -> &str;
}

// One bridge, written once, gives every Elm component its Facet impl for free:
impl<T: Elm> Facet for T {
    fn title(&self) -> &str { Elm::title(self) }
    fn ui(&mut self, ui: &mut egui::Ui) {
        for m in self.view(ui) { let _ = self.update(m); } // effects drained by host
    }
    fn state_json(&self) -> serde_json::Value {
        serde_json::to_value(self.state()).unwrap_or(serde_json::Value::Null)
    }
}
```

Why first: it collapses FC-2 + FC-3 + FC-8 + FC-9 into a **single** per-component impl,
removes the per-component `ui()` bridge boilerplate (security writes it by hand today),
and gives the nornir gate a uniform `state()`/`view()` seam to drive headlessly.
`orphan-rule` caveat: a blanket `impl<T: Elm> Facet for T` may collide with existing
hand-written `impl Facet` blocks during migration — expect to convert component-by-component
(remove the hand `impl Facet`, add `impl Elm`) rather than flip a switch. A helper macro
(`elm_facet!{ … }`) is the fallback if the blanket impl proves too coarse.

**Second infra item:** a headless driver in `facett-core::harness` that, given an `Elm`
component, applies a `Vec<Msg>` and snapshots `state()` + AccessKit — this is what turns
FC-2 into a *tested* property (`proptest` over `update`, per contract §5.1).

## 5. Proposed rollout order

**Phase 0 (infra, blocking):** land the `Elm` trait + blanket/macro `Facet` bridge +
harness driver in `facett-core` (§4). Re-express `facett-security` on top of it as the
first consumer / migration proof (it already matches the shape).

**Phase 1 — first 3 components (T1, Msg already exists):**
1. **facett-helix** — smallest T1; controls already route through `update`; extract pure
   `view`, adopt injected clock (already present), derive serde.
2. **facett-timeline** — same shape; the canvas-pick → `Msg` work is the added bit.
3. **facett-plandag** — same shape; more controls + a composed `DepGraphView`.

**Phase 2 (T2 display):** pop → stat → card → log → docview → matrix → tabbar → plot →
plotters(×5) → syschart. Mechanical once Phase 0 exists.

**Phase 3 (T3 interactive data):** console → jobview → filedialog → pipeline → table →
grid → icebergview → warehousedeck → graph → graphpan → graphview → git.

**Phase 4 — last 3 (T4, big lift), hardest last:**
… → model3d → graph3d → **facett-map3d → facett-geomap → facett-video** (final three).
Each gets its own camera/render-path design note before conversion.

**First 3 components:** helix, timeline, plandag.
**Last 3 components:** map3d, geomap, video.

## 6. What this schedule deliberately does **not** do

- It does **not** touch any component's Rust source (audit + doc only).
- It does **not** relax FC-1/3/4/5/6/7/8 — those remain enforced on the existing trait per
  the contract; this schedule sequences **only** the FC-2/FC-9 architectural move.
- It does **not** claim runtime-verifiable FCs (4, 5, 6) are passing where a static read
  can't prove it — those stay 🟠 until an `egui_kittest` / offscreen-render pass confirms.
</content>
</invoke>