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 ElmMsg
- 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):
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.memoryusage in the tree is theme/keymap/effects infra insidefacett-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 componentsrc. Animated components inject a clock (e.g.facett-helixclock_s, falling back toui.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|channelover componentsrcis empty). Data is host-fed; every component is idle-by-construction.security's emptyEffectenum 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 (aModelderivingSerialize+Deserialize+Clone+PartialEqand astate(&self)->&Modelaccessor) is met only byfacett-security. Several carrySerializeon 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_kittestretrieval (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;securityis ✅ (emitsWidgetInfo+ 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_kittestuses, 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
Msgenum +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()callsself.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 mutatesself→ not a pure view. - Delta: split
ui()intoview(&self, ui)->Vec<Msg>(collect, don't apply) + the security-style bridge. Changeupdate's return frombooltoVec<Effect>(with an emptyenum Effect {}— all three are sync, FC-8-trivial). Addstate(&self)->&Modeland 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):
// 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):
- facett-helix — smallest T1; controls already route through
update; extract pureview, adopt injected clock (already present), derive serde. - facett-timeline — same shape; the canvas-pick →
Msgwork is the added bit. - 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.