<!-- 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.