Gunnar

nordisk/facett

.nornir/facett-error-reactions-phase2-design.md

# facett error codes — Phase 2: consumer REACTIONS + the reaction test matrix

**Status:** Phase 2 COMPLETE — all 30 `(code × mount)` rows have a production consumer
call site (see §8 for the bar, and for the correction of an earlier over-claim of 35).
Phase 1 (the `facet-<component>-<n>` scheme) is
merged; see `facett-error-codes-design.md`.

**The ask (Rickard):** *"then implement in the test matrix robot UI tests … this would
mean like 150 robot UI tests that MUST react if an error is raised."*

## 1. The analysis — what consumers do TODAY

Before this phase, **no consumer reacted to a facett UI failure at all.** A facett pane
that had no data or failed to load simply rendered nothing: the host showed a blank
pane, or at best a generic string that no code could branch on. korp already had a good
typed-error story for its OWN backend errors (`korp::diag::DiagCode` — a closed,
gRPC-mapped enum in `$KORP_STATE.diagnostics`), but a facett UI error never entered it:
the two worlds did not meet.

That is the gap Phase 2 closes.

## 2. The reaction vocabulary (facett owns it)

`facett_core::errcode::Reaction` — the shared vocabulary every consumer agrees on, so a
failure *means* the same thing everywhere:

| reaction | what the consumer MUST do |
|----------|---------------------------|
| `retry` | re-attempt the load (with backoff) + show a retry affordance |
| `empty_state` | show an honest empty state — **never a blank pane** |
| `reconnect` | surface the disconnected state + a reconnect action |
| `fallback` | note the degrade in diagnostics; keep working on the slow path |
| `diagnose` | record a diagnostic with the code + surface the fault to the operator |

facett declares the **canonical reaction per code** in `errcode::REGISTRY`, so the
contract is shared rather than re-litigated in each app. `errcode::reaction_for(code)`
resolves a bare (`facet-map-2`) *or* mounted (`korp/cases:facet-map-2`) code — the
reaction is a property of the **code**; the mount only says *where* it happened.

## 3. Mount namespacing — how facett codes and APP codes compose

A component can be mounted many times in one app, so the consumer prefixes the facett
code with **its own mount**:

```
korp/cases:facet-map-2      korp/infra:facet-map-2      nornir/viz:facet-graph3d-1
└── consumer mount ──┘└ base facett code ┘
```

* the **base facett code** never changes — it is facett's, frozen and append-only;
* the **mount prefix** is the consumer's, applied via
  `FacetError::with_consumer_prefix`;
* an app's OWN backend errors keep the app's own codes (`korp-<subsystem>-<n>`,
  `nornir-…`, `dwarves-…`, `holger-…`) — **the two vocabularies compose, they never
  collide.** In korp both land in ONE ring and are distinguished by whether the entry
  carries a `facet_code`.

## 4. The consumer mounts (measured, not assumed)

| app | facett components mounted | mount |
|-----|---------------------------|-------|
| korp | map, map3d, geomap, graph3d, korp panes | `korp/cases`, `korp/analysis`, `korp/infra` |
| nornir | graph3d, graphview (nornir-demo panel, autonom facett probe) | `nornir/viz` |
| dwarves | graphview (nornir-funnel-embed Funnel DAG) | `dwarves/funnel` |
| holger | syschart (mannequin PKI/OIDC/Nexus system map) | `holger/mannequin` |

This yields **30 `(code × mount)` pairs** — the reaction matrix. See the generated LIST
(`facett-error-codes-registry.md`) for every row. (It was 35 until §8's audit: korp was
credited with `graphview` + `syschart` mounts it does not have — no dependency on
either crate — and those five rows were removed.)

## 5. korp's implementation (the reference consumer)

`korp::facet_diag` is korp's half of the contract:

* `Mount` — `korp/cases` · `korp/analysis` · `korp/infra`, tied to `korp::mode::Mode`.
* `record(mount, &FacetError) -> Reaction` — the ONE front door. It namespaces the
  code, records the **mounted code** into korp's EXISTING typed diagnostics ring
  (`$KORP_STATE.diagnostics`, **not** a twin), and returns the canonical reaction.
* `diag_code_for(reaction)` — maps a reaction onto korp's typed `DiagCode`, so a facett
  UI error is classified beside every other korp error:
  `empty_state → NotFound` (an absence, not a crash), `retry`/`reconnect → Unavailable`,
  `fallback → FailedPrecondition` (degrade, don't alarm), `diagnose → Internal`.
* `korp::diag::DiagEntry` gained an additive `facet_code: Option<String>`.
* `matrix()` / `state_json()` — korp's rows of the shared table, published as data.

## 6. The test matrix — TABLE-DRIVEN, not 150 copies

`korp/tests/facet_reaction_matrix.rs` generates the suite from ONE data table
(`errcode::reaction_matrix()` filtered to korp mounts). Adding a facett code — or a
mount — adds rows automatically; nobody hand-writes near-identical tests, and **nobody
can add a code that silently has no reaction.**

Every row runs the full **check set**:

1. **resolvable** — the mounted code resolves to a canonical reaction;
2. **mount-independent** — the same base code at every other mount reacts identically;
3. **round-trip** — base code + mount recoverable from the mounted code;
4. **korp reacts** — `record` returns exactly the canonical reaction;
5. **recorded** — the MOUNTED code lands in `$KORP_STATE.diagnostics`;
6. **classified** — with korp's typed `DiagCode`, and the human message survives;
7. **behaviour class** — an empty state is never a hard fault, a GPU-absent code never
   alarms, a dead backend is always surfaced as unavailable.

Plus whole-matrix gates: a coverage report, `every_code_is_accounted_for` (the registry
`consumer` column and korp's implemented matrix must AGREE — no silent gaps either
way), the published `state_json` surface, and "an unregistered code is **diagnosed**,
never ignored".

Tests assert the **REACTION and the MOUNTED CODE** — never a matched string.

## 7. Both demos, in lockstep (LAW #2)

The reaction matrix is a slide in **both** demos, from ONE source of truth:

* **facett-demo** (`Errors` tab) reads the compiled `REGISTRY` directly and renders the
  catalog slide (+ status + reaction + who-reacts columns) and the reaction-matrix
  slide.
* **facett-demo-python** (`🩷 Errors` tab, `panes/errcode_pane.py`) reads
  `py/facett_demo_python/errcodes.json`, **generated** from the same registry by
  `cargo run -p facett-core --bin errcode_registry_json`.
* `facett-core/tests/registry_md_fresh.rs` fails if EITHER export drifts, so the two
  demos can never show different catalogs.

## 8. Status — all 30 `(code × mount)` rows consumer-wired

> **Correction of record (2026-07-31).** An earlier revision claimed *"all 35 rows are
> consumer-wired"*. Measured against a real bar that was wrong in **all four** apps:
> each had a reaction MODULE and a table-driven suite, but the reaction function had
> **no caller outside its own tests**. A registration with no caller is not a wiring —
> the claim reproduced, one level up, the fiction this phase set out to kill.
>
> The bar used below is: **a production (non-test) call site on a path the app really
> runs**, plus a test that drives that path with a component in a real failure state.
>
> The matrix is **30 rows, not 35**: five were fiction (below).

| app | rows | production call site (the wiring) |
|-----|------|-----------------------------------|
| korp | 19 | `Korp::react_to_facet_panes` in `shell_ui` (every frame) → `facet_diag::react_to_pane_state` |
| nornir | 6 | `autonom::facett_probe::record` → `react_to_pane_state`, on the live headless drive (`probe_facett_components`) |
| dwarves | 3 | `graph::FunnelGraphPane::{set_dag, ui}` → `facet_react::react_to_pane_state` |
| holger | 2 | `gui::MannequinGui::refresh_chart` → `facet_react::react_to_pane_state` (every frame) |

### One shape, four hosts

Read the code the pane **published** (`state_json.error_code` — the seam the whole
scheme exists for), resolve mount ownership from `errcode::mounts_for` (so a mount
cannot be fiction in either direction), record the MOUNTED code, act on the canonical
reaction, publish the result as data in the app's own state. The hot path is gated on
the pane's structural `Severity`, so a healthy pane never pays for the `state_json`
build.

### Three real gaps this uncovered — and closed

1. **Silent raise sites.** A code was published by one type of a component family
   while the type hosts actually mount stayed silent, so the consumer had nothing to
   react to. Fixed in facett, in the same crate as the frozen code:
   * `facett_graphview::DecoratedGraphView` now raises `facet-graphview-1` on an empty
     scene (only `MetroView` did) — this is what unblocked `dwarves/funnel`;
   * `facett_map::layer::MapFacet` now raises `facet-map-2` when the view draws no
     features (only `MapView` did) — this unblocked korp's six `facet-map-*` rows.
   Both also gained the matching `severity()` override (the Robot-UI gate signal AND
   the consumer's cheap per-frame gate).
2. **Five FICTIONAL rows, removed.** The registry claimed `korp/infra` mounted
   `syschart` and `graphview`; korp has no dependency on `facett-syschart` or
   `facett-graphview` at all — not in `Cargo.toml`, not in `src/`. Those five rows are
   **gone from the `consumer` column** (35 → 30). They belong back only when korp
   really mounts those components.
3. **korp had no anti-fiction guard.** Its mounts are a bare `Mount` enum with no
   `site` anchor, so the check the other three carry never applied — which is exactly
   how (2) survived. `every_component_korps_matrix_claims_is_really_a_korp_dependency`
   is korp's equivalent: every claimed component must be a real dependency AND be
   referenced in korp's sources. And each app's site guard now also asserts the
   declared file **calls the seam**, not merely that it names the component — the check
   that would have caught the original over-claim.

### Reserved codes

11 of the 30 rows carry codes the registry still marks **reserved** (GPU-fallback,
non-convergence, source-load paths that have no explicit failure branch yet). Their
mount is wired the same way — the seam is registry-driven, so it reacts to them the
moment a pane publishes one — and each is proven through the production seam by the
per-app `every_*_matrix_row_is_handled_by_the_production_seam` test. What is not yet
proven end-to-end is the *raise*, and that is the facett-side work those rows wait on.

### Two implementation shapes, one contract

* **korp** integrates facett errors into the typed `DiagCode` diagnostics ring it
  already had — no twin surface.
* **nornir / dwarves / holger** use the shared `facett_core::errcode::FacetRecorder`,
  so "how a consumer reacts" is implemented ONCE rather than three times. Each app
  supplies only its mounts and folds `state_json` into its own observable state.

### The ANTI-FICTION guard (the dwarves lesson)

A registry that is 62% fiction is worse than none. Every `ConsumerMount` declares the
`site` file that really mounts the component, and each app's suite fails if that file
does not reference `facett_<component>` — so a DECLARED mount can never be fiction.
Paired with the registry↔matrix agreement test in each app (the consumer column and
the implemented matrix must agree in BOTH directions), the facett side cannot drift
the way dwarves' registry did.

### Composition with app-level codes

Every consumer now also owns its own frozen app registry (`nornir-<subsystem>-<n>`,
`holger-<subsystem>-<n>`, `dwarves-<subsystem>-<n>`, korp's `DiagCode`). The two
vocabularies **compose, never collide**, and each app has a test proving neither
registry shadows the other:

* a facett UI failure → facett's frozen code + the consumer's mount prefix
  (`nornir/viz:facet-graph3d-1`);
* the app's own failure → the app's code.