Gunnar

nordisk/monetize

UI.md

<!-- nornir:class: design -->
# The console — what it is, and what it must stop being

Written 2026-09-05, against a live console whose first tab was a black page and
a mass of text: the details belong behind a disclosure, not on the surface.

This is the spec. `BASE-MODEL.md` is the model; this is what the model looks
like on a screen. Build against this, and delete what it replaces.

---

## What this console IS

**An administrative system for resource allocation.** Money arrives, resources
get bought, tenants consume them. The operator needs three things and nothing
else on the surface:

1. **each individual** — what one tenant consumes, is allowed, and is backed by
2. **the system as a whole** — the fleet total against what the iron can serve
3. **trends** — where both are heading, and how long there is

**monetize serves many products.** gunnar is *one row*, not the application.
holger and any further product are peers. Nothing may assume a single product.

## The six rules of the surface

1. **One number per screen is big.** Everything else is a row.
2. **Never a wall of text.** A paragraph lives behind a badge you tap, never on
   the page. The `cannot grow` sentence is the test case: it is important and it
   is three lines long, so it is a badge.
3. **Plain words on the surface, jargon underneath.** "wall", not `Enforced`.
   "still costs", not `tombstoned_bytes`. The precise name appears in the detail.
4. **A trend is one line.** `▲ 12 GiB/wk` · `Full in ~9 weeks`. Never a chart
   the operator has to interpret. A projection carries its uncertainty (`~`) or
   it is a confident wrong date someone plans a re-image around.
5. **Sort by who needs attention**, never alphabetically. The tenant at their cap
   is the first row.
6. **Outcomes, not steps.** `Sold bob 90 GiB · 1 980.00 SEK` is the row; the
   six ledger steps live behind a disclosure. An operator reads outcomes and
   opens steps only when something is wrong.

---

## The ESTATE is the top level (owner 2026-10-02)

The console administers several estates — gunnar.rs, a company's
gunnar.acme.se, test2.gunnar.rs, holger.rs — and every one of them is an equal,
full estate. There are **no roles and no labels**: nothing on any screen calls
an estate production, test or private. The one difference between two estates
of the same product is a SETTING — which Stripe keys it holds, live or test —
shown as `Stripe: live` / `Stripe: test` on the Overview and under Settings.

```
[ gunnar.rs ▾ ]   Overview                                         You ▾
  gunnar                 ┌──────────────┐
    gunnar.rs            │ Map          │  the same left menu for EVERY estate
    gunnar.acme.se       │ Customers    │
    test2.gunnar.rs      │ Money · Tax  │
  holger                 │ Offer        │
    holger.rs            │ Events       │
    test2.holger.rs      │ Settings     │
                         └──────────────┘
```

* The picker groups estates under their product; the product is a heading and
  nothing more. There is no Products menu: what an estate sells is its Offer.
* **Overview** is the only page that spans every estate: one row per estate —
  its map as a thumbnail · Estate · Machines · Customers · Revenue/mo · Stripe ·
  Health. The total counts LIVE money only, by each payment's own mode; test
  money is that row's bracket, `(80.00 EUR)`, and never in the total. An
  estate the fleet has not heard call home is named (`not reached`) — never a
  blank square.
* **Only estates that exist (OVW-CLEAN, owner 2026-10-29):** the rows are what
  is installed or being laid (ordered, paid, laying), as monetize classifies each
  record it keeps (`Estates.Overview`: `installed`, `laying`, `ended`,
  `never_installed`). What ended or was never installed is ONE quiet line,
  `N ended or never-installed`, that opens to name them. There is no
  "not installed" row. The last row is the one empty slot, **New estate**: a
  name and a product (and the ready mail's address); monetize makes it — a
  declared site is ordered as it is, a new name is copied from a declared site
  of its product into the site book, its domain from its name — and hands it to
  the ONE site order (`Estates.NewEstate` → `OrderSite`): the same payment page,
  `Pay <amount>`, and the same lay. A declared site that was never ordered is no
  row; the slot offers it by its name.
* **ONE monetize (5.4, UI-1):** the console reads everything from the ONE
  monetize — estates from its fleet (`Guardian.Cases`: declared, heard, silent),
  customers from its ledger under `gunnar/<t>`, `gunnar/@<company>/<ns>` and
  `holger/<t>`. It dials no estate; `--estates` and the `x-monetize-estate`
  header are LEGACY.
* Every page of an estate is under `/e/<estate>/…`. The map is ADDRESSED BY
  ESTATE: `GET /api/estates/<estate>/map`, drawn by the ONE for that estate's
  product. A product that declares no `[machines.*]` says "this estate names no
  machines yet" instead of showing a blank board.

Where the screens below now live: 1 · Products → the product lines on Map, the
spend on Money, the terms on Offer; 2 · A product → Customers (tenants, sales,
silent, friction) and Map (fleet, iron, growth, the board); 3 · A tenant →
Customers; 4 · Activity → Money, its refused deliveries → Events; 5 · Tax →
Money → Tax.

## ACCOUNTS — ONE monetize (5.4)

`Accounts` in the top bar (it replaces the old top-level Customers page;
`/customers` opens it too). One monetize serves several products, each billed
through its OWN Stripe. An account is `(product, tenant)`; members are never
accounts. A Private Gunnar is another ROW on its gunnar account.

```
Accounts                                     GET /api/accounts
product | tenant | rows                                  | Stripe               | status
gunnar  | acme   | SaaS plan · 1 Private Gunnar instance | stripe/gunnar · live | paid until 2026-11-05
holger  | acme   | SaaS plan                             | stripe/holger · live | paid until 2026-11-05

/account?product=gunnar&tenant=acme          GET /api/accounts/gunnar/acme
kind             | plan / stop        | host           | last heard | billing                | since
saas             | 10gb               | —              | —          | paid until 2026-11-05  | 2025-10-01
private-instance | 132.00 EUR a month | acme.gunnar.rs | never      | ready until 2027-04-07 | 2025-10-08
Payments: when | reference | outcome | what
```

Pull model: monetize never reaches into an estate; every estate calls monetize.
`last heard` is how long ago a private instance's estate last called home
(`2 min ago`), `never` until the pull channel lands; `—` for the SaaS plan.
The cells live in `monetize_ui_web::accounts`
(host-tested); `tests/accounts.rs` draws both screens from the server's answers.

## GUARDIAN: repair cases per estate (5.4 resilience)

`Guardian` in the top bar. `/guardian` lists every estate (main/twin last
heard, auto-fix, open case). `/guardian/estate?instance=…` shows the boxes,
the four buttons **Recharge from twin · Review twin · New twin · New
server**, the auto-fix switch, the cases (newest first) and the newest case's
steps, evidence, log and receipt.

```
[ Recharge from twin ] [*Review twin*] [*New twin*] [ New server ]
Auto-fix: OFF (opt-in) — a person approves every repair
```

- A case runs detected → investigating → approved → running → done | failed.
  Only one repair runs at a time: while a case is approved or running, every
  button is disabled. The open case's suggested remedies are marked `*`.
- A press needs a STRONG session: admin, passkey user-verified, and
  authenticated ≤ 5 min ago. The BFF refuses anything weaker (403 "log in
  again"). An admin token or an open console is never strong. Monetize records
  the principal on the case.
- Auto-fix is each estate's opt-in, OFF by default. When it's on, the guardian
  approves its own case (`approved by: guardian (auto-fix)`).
- Monetize never reaches into an estate. A press queues a SIGNED order that
  the estate pulls on its next heartbeat (recharge, review, adopt), or it
  creates a box through the cloud and the installer lays it (new twin/server).
- API: `GET /api/guardian`, `GET /api/guardian/{instance}`,
  `POST /api/guardian/{instance}/press {remedy, case_id}`,
  `POST /api/guardian/{instance}/auto-fix {on}`. The cells live in
  `monetize_ui_web::guardian` (host-tested).

## The five screens

### 1 · Products — the whole estate, one line each

```
monetize                                              ⌄ alice

  PRODUCTS

  ● gunnar        9 tenants     184 / 300 GiB      ▲ 12 GiB/wk
                  ────────────────────────░░░░     61 %

  ○ holger        not connected

  Lifetime      €2.26 spent  ·  €10.00 cap        ██░░░░░░░░  23 %
```

A product is a row. The dot is reachability. The spend line is the LIFETIME cap
(the €10 is a total, ever, not a month's — so the word is `Lifetime`, and
`this month` appears only when a monthly window is configured),
because that is the number that stops everything. The figure is the ledger's
fold, never the cap's cache; `no ceiling` is said in words, because an unbounded
account is the one dangerous thing this screen must not hide.

### 2 · A product — the fleet

```
← Products                                              gunnar

         184 GiB used of 300
         ██████████████████████░░░░░░░░
         Full in ~9 weeks  (12 GiB/wk, last 30 d)
                                              ⚠ cannot grow

  TENANTS                                        sort: closest ⌄
  alice        10.2 / 10 GiB   ████████████  AT CAP    →
  bob          3.1 / 10 GiB   ████░░░░░░░░  31 %      →
  + 6 more                                               ⌄

  RECENTLY BOUGHT
  Sep 4   Sold bob 90 GiB           1 980.00 SEK    ✓
  Sep 4   Refunded zz-visibility 1 GiB     1.00 SEK    ↩ refunded
                                              all activity →
```

A RECENTLY BOUGHT line names the DELTA — what was sold, `90 GiB` on a tenant
now allowed 100 — and the price the customer paid; the provider's cost sits in
the row's title, never added to it.

`⚠ cannot grow` carries `GROWTH_SEALED` — a badge holding the paragraph that says
a bought disk would be billed and invisible, and that the remedy is a fresh
install onto a larger set, **not another disk**.

**ESTATE** is the same product's IRON, drawn: one board, one chip per machine
(`GET /api/estates/<estate>/map`, an `image/svg+xml` rendered by monetize, not by
this console). Each chip carries the provider id an operator pastes into a
ticket, the plan, and the disk — with the plan labelled `plan says` and a
reading labelled `measured`, because they are different kinds of fact. A box
nobody could ask draws a dash, never a zero. A board with NO chips is the
honest picture of a deployment that has not declared `[machines]`, and the
caption under it says so and names the verb that writes the block.

### 3 · A tenant

```
← gunnar                                              alice

         10.2 GiB of 10 GiB
         ████████████████████  AT CAP
         ↑ 1.4 GiB/wk — hit the cap 3 days ago

                    [ How much? ]

  USING
  Repository data      10.2 GiB / 10 GiB   ████████  wall
  Cache                10.9 GiB            ─────     not billed
  Deleted, not purged  83 MB               ─────     still costs
  Repositories         26                  ─────

  BACKED BY
  10 GiB disk · se-sto1 · bought Sep 4 · 220.00 SEK      ⌄

  History                                                →
```

`wall` / `billed` / `not billed` / `still costs` are rule 3, and they are read off
the PRICE LIST, not invented: a meter is `billed` iff the product's list charges
for its unit in the tenant's market, `wall` is what `Enforced` says whatever the
list says, `still costs` is deleted-not-purged bytes the list still charges for.
With no list at all — the open-source deployment — everything but a wall reads
`not billed`. `BACKED BY` is rule 4 of the base model made visible — what was
*bought*, distinct from what is *allowed*.

**`How much?` is the order.** A form of caps — disk in GiB; CPU and RAM only
where the product meters them per tenant — pre-filled with what the tenant
holds, answered BEFORE any money moves with three lines:

```
  Sells      90.0 GiB — 90 GiB on gunnar-appliance
  Price      1 980.00 SEK / month          ("free" when the list says 0, or there is none)
  Cost       20.70 EUR                     ("this box · nothing to buy" on the server's own iron)
  Can serve  yes                           (or "no", with the product's sentence, whole)
                              [ Confirm: take the money ]
```

Only the raise over what the tenant holds is priced and bought (an order for
100 GiB on a tenant holding 10 sells 90 — measured before the delta existed:
110 GiB held and billed where 100 was asked). A downgrade, a product that
cannot deliver, a box past its ceiling and a price nobody linked can take are
refused by name before the confirm button does anything. There is no plan to
pick from: the price list is `monetize.toml`'s and is not a screen.

### 4 · Activity — the ledger, de-jargoned

```
← gunnar                                             Activity

  NEEDS YOU  1
  ⚠ zz-visibility — paid, never delivered      Sep 4    →

  Sep 4   Sold bob 90 GiB            1 980.00 SEK    ✓
          └ reserved → bought disk → charged → granted  ⌄
  Sep 4   Refunded zz-visibility 1 GiB      1.00 SEK    ↩
```

Open attempts float to **NEEDS YOU**, because an unfinished attempt is the only
row that is actually a task. Everything else is history.

---

### 5 · Tax — the same ledger, in somebody else's form

```
                                                   Tax   ← 2026 →
  SE · filed quarterly · turnover 250 000.00 SEK in 2025
  [ 2026-Q1 ] 2026-Q2  2026-Q3  2026-Q4

  SE · 2026-Q1
  05  Momspliktig försäljning som inte ingår i …    1 500.00 SEK
  10  Utgående moms 25 %                              375.00 SEK
  20  Inköp av varor från annat EU-land                       —
  21  Inköp av tjänster från annat EU-land, …          443.50 SEK
  30  Utgående moms 25 %                              110.88 SEK
  48  Ingående moms att dra av                        110.88 SEK
  49  Moms att betala eller få tillbaka               375.00 SEK

  ⤓ Download monetize-se-2026-Q1.csv
```

Box · label · amount, in the authority's own numbering and the authority's own
words, and a CSV of exactly those lines for the accountant. Datasheet tone: no
advice, no "you owe", no rhetoric.

**A `—` is not a zero.** An authority that asks for a 0 in a box means the
digit; a box monetize could not fill is a box nobody computed, and the page
never blurs the two. Which boxes exist, what the rates are and how long a period
is belong to a JURISDICTION, and a jurisdiction is a linked implementation — so
a deployment that has linked none still gets this page, listing what a return is
folded from with `—` in every amount and the reason in the notes. An empty
screen would be a lie about a deployment that has simply not said where it
files.

---

## What must be BUILT (it does not exist)

* **A rate.** Nothing computes a slope. `Usage.History` keeps 400 days and
  **nothing reads it**. Needed: bytes/week per tenant and for the fleet, over a
  stated window, with enough honesty to say "not enough history yet".
* **A fleet total.** Nothing sums tenants, and nothing compares that sum to what
  `Admin.Capacity` says is servable.
* **"Recently bought".** The ledger holds it; nothing summarises an attempt into
  one outcome line.
* **Reachability per product.** The Products page needs a dot, which means asking
  each product whether it answers.

## What must be DELETED (it is what he is looking at)

* The Products page's prose block — the "huge text mass".
* Raw meter names on the surface (`pack_bytes`, `tombstoned_bytes`,
  `open_store_ram_bytes`) — they move into the detail.
* The Plans page, its route and its door — GONE on 2026-09-05 with the plan
  catalogue itself. There is no plan vocabulary on any surface: an order is caps,
  the price list is `monetize.toml`'s, and the Products screen shows the terms
  in one line (`gunnar · SE 22.00 SEK / GiB / month`, or `free`).
* The Ledger page's step-by-step dump as the default rendering.

## Scope is the PRODUCT's call, not a global rule

Each product declares its own meters and their scope. gunnar today: disk per
tenant, CPU/RAM per product — a tenant does not own cores. Another product may
legitimately meter CPU per user, or disk only in total. `Product::meters()` is
already that seam; nothing outside a product decides which of its meters are
per-tenant. **The fleet total sums only `Scope::Tenant`**, whatever a product
declares, because summing a figure that was never measured per tenant is
arithmetic on nothing.

## ON THE LIST, not built: resource allocation in gunnar's own web UI

gunnar-ui already shows a namespace its measured usage (`/usage`). It should
also show **allocation** — what room there is, what a namespace is allowed.

**The open-source gunnar-ui has no money in it and must not learn any.** No
price, no order, no invoice, no "upgrade", no billing link — not in a string, not
in a tooltip, not behind a feature flag that could be flipped on by accident.
Resources only: used, allowed, remaining. A self-hoster who will never pay
anyone must find the page exactly as useful as a paying tenant does.

How the paying half reaches a tenant is a later problem and is deliberately not
solved here. Whatever the answer, it does not put money vocabulary into the
open-source console.

## What must NEVER appear

From `BASE-MODEL.md`: no "top up" or credit affordance (there is no balance); no
number that cannot be rebuilt by folding the ledger; no measurement taken by the
console itself; no projection stated as a date without its uncertainty.