Gunnar

nordisk/facett

.nornir/facett-error-codes-phase3-design.md

# facett error codes — Phase 3: MOUNT identity + developer mode

**Status:** design, not implemented.
**Anchor (Rickard, 2026-07-31):** *"go through consumers and make sure they also create
unique errors for their respective application, so robot UI — for example korp — can
find out that if the same facet UI is used in 2 places in korp, it has individual error
report (but ok, same base UI component)."* Plus: *"we want a developer mode, where a pink
text appears with the facet-error name, or id, of that component."*

Phases 1 and 2 are merged (`facett-error-codes-design.md`,
`facett-error-reactions-phase2-design.md`). This phase closes the two gaps those left.

---

## 0. Where Phase 1+2 actually landed — measured 2026-07-31, not assumed

| | |
|---|---|
| `REGISTRY` | 13 components, 38 codes (`facett-core/src/errcode.rs:182-278`) |
| consumer facets carrying **any** code | **1 of ~51** — only `facett-korp` |
| real raise sites in the whole workspace | **12**, and **every one is the `EmptyState` half** |
| registered "could not read" twins never raised | **9** — `map-4`, `map3d-4`, `graph3d-4`, `systemmap-3`, `syschart-3`, `korp-3`, `korp-4`, `korp-5`, `korp-6` |
| facets overriding `Facet::severity()` | **10 of ~80** — everything else structurally reports "I am showing real data" while blank |
| what the raise-site guard scans | `facet_err!(` — **2 sites, both in `facett-demo`**. The 12 real `.error_code("` sites are invisible to it. |

**The catalog got its twins; the panes are still blind.** That asymmetry is the whole
reason ⚒ Build Thing read green four times while empty.

---

## 1. The gap Phase 3 closes — identity is per-COMPONENT, not per-MOUNT

`facet-<component>-<n>` answers *which component failed*. It does not answer
*which instance of it*. When korp mounts the same graph pane twice, both raise the
identical code, and:

- a robot-UI test cannot assert **which** pane broke;
- a consumer cannot react differently to the two mounts (retry one, diagnose the other);
- two failures that are genuinely different are, once again, indistinguishable — the
  exact defect class this whole scheme exists to kill, one level up.

**The concept already exists on the test side.** Phase 2's matrix is defined in
`(code × mount)` rows — *"all 30 `(code × mount)` rows have a production consumer call
site"*. The matrix has always assumed a mount identity that the runtime never carried.
Phase 3 supplies it.

### The shape

A **`MountId`** assigned by the HOST at mount time, never by the component:

```
korp.graph.left        korp.graph.right
korp.map.main          dwarves.buildthing.deck
```

- The component stays **one shared UI part** — no forking a facet to get a second
  identity. Rickard's constraint: *"but ok, same base UI component."*
- The raised error becomes the pair **`(code, mount)`**. `code` says what broke;
  `mount` says where. Neither alone is sufficient.
- `MountId` is a host-owned string with a **stable dotted form** `<app>.<surface>.<slot>`,
  so `korp.graph.left` is greppable, sortable and diffable across runs.
- It rides in the **same dual channel** the code already uses — never a second mechanism.

### Consumer-side codes are a separate axis, and both are needed

Rickard also asked that consumers *"create unique errors for their respective
application."* That is not the same as `MountId`:

| axis | owner | answers | example |
|---|---|---|---|
| `facet-<component>-<n>` | **facett** | which UI part failed | `facet-graphview-1` |
| `MountId` | **host** | which instance of it | `korp.graph.left` |
| `<app>-<sub>-<n>` | **consumer app** | what it means to THIS app | `korp-graph-7` |

korp already has the third axis (`korp::diag::DiagCode`, gRPC-mapped, in
`$KORP_STATE.diagnostics`), and Phase 2 explicitly joined it to the facett axis. What is
missing is the middle column. A pane that fails should be able to say, in one typed
payload: *"`facet-graphview-1` at `korp.graph.left`, which korp classifies as
`korp-graph-7` and reacts to with `Reaction::Diagnose`."*

### Non-negotiable properties

1. **No string parsing anywhere.** The original anchor stands: *"not only the string
   tells it's wrong."* If a robot test has to `contains()` its way to a mount, this
   phase failed.
2. **A mount id must be stable across runs** or every pinned test rots.
3. **Absent mount id is an error, not a default.** A facet mounted without one should be
   loud in dev mode, not silently `""` — silent defaults are how the 9 unraised twins
   happened.
4. **`severity()` must be part of the same rollout.** A mount identity on a pane that
   always reports `Severity::Info` while blank buys nothing.

---

## 2. Developer mode — make the identity visible

**The ask:** a toggle that paints, on each facet, a **pink** label carrying that
component's error name / id.

This is the *observability* half of the same feature. C makes the identity exist;
D makes it visible so a human can see what a test is about to pin.

### Design

- **Toggle:** env `FACETT_DEV_IDS=1`, a CLI flag, and a keyboard chord — all three, since
  the demo, the robot harness and a live app each reach it differently.
- **What it paints:** `facet-<component>-<n>` when a code is live; otherwise the
  component slug plus its `MountId`. Pink (`look::` accent, not a hardcoded literal —
  `facett-gantt` is the cautionary case, it bypasses `Theme` with 8 hardcoded
  `Color32::from_rgb`).
- **Where:** a corner badge, drawn last, never displacing layout — dev mode must not
  change what it is describing.
- **★ It MUST also land in the accessibility tree, not only in pixels.** This is the
  load-bearing detail. Robot tests should read the id as a **node**, never OCR a colour.
  Today `facett-korp`'s two panes are the only ones putting codes in the a11y tree via
  `.error_code("…")`; dev mode should extend that, not invent a parallel channel.
- **Verification:** assert the badge in the a11y tree with the harness, and assert its
  ink with `RenderReport::ink_colors` — the pink must actually be emitted, not blended
  away. Note `assert!(r.drew())` cannot catch this: a glyph is four vertices at 6 pt and
  at 60 pt, and `drew()` is blind to colour entirely.

---

## 3. Order of work

1. **Widen the raise-site guard beyond `facet_err!(`** to `.error_code("`. It currently
   guards 2 demo sites while 12 real ones go unchecked. Cheapest, and it makes every
   later step measurable.
2. **Raise the 9 registered-but-never-raised "could not read" twins.** The catalog
   already has them. Start with `korp-3` in `GraphPane`, which has **no error slot at
   all** — `GraphModel` cannot represent a failure, so a failed Cypher query renders
   identically to a query that returned nothing.
3. **Fix `facet-korp-1`'s meaning.** It is registered as *"korp backend connection not
   established → `Reaction::Reconnect`*, but its only raise site fires when the user
   pressed Run with no collector row selected, and `severity()` goes RED for it. A
   consumer following the table will **attempt to reconnect because someone clicked a
   button without selecting a row.**
4. **`SearchPane` cries wolf** — `severity() == Error` whenever `results.is_empty()`,
   including a pristine never-queried pane whose own label says *"type a query and
   Search"*. A permanently-red tab desensitises the gate; that is the other half of why
   a genuinely empty pane went unnoticed.
5. **Then `MountId`** — plumb it, make absence loud, and land it with `severity()`.
6. **Then developer mode**, which is the cheapest way to verify 1–5 actually took.

## 4. The model to copy

`facett-deploykey` — cited by `facett-buildthing`'s own doc comment as its model —
carries a four-value `state_label` (`"NO KEY FOUND"`, `"KEY PRESENT — NEVER USED"`,
`"MINTED — NOT REGISTERED"`, …) and renders **every path it looked at, in order**, with
a red *"NO rung answered — every path below was empty"*.

That is the target shape: **an enumerated evidence trail, not a claim.** Compare the
three current empty states, each of which asserts *why* it is empty while having no
error path that could ever have told it otherwise — *"this is an ABSENT declaration, not
an empty build space"*, *"this variant is not declared by the build thing"*, *"no build
yet"*.