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:
- each individual — what one tenant consumes, is allowed, and is backed by
- the system as a whole — the fleet total against what the iron can serve
- 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
- One number per screen is big. Everything else is a row.
- Never a wall of text. A paragraph lives behind a badge you tap, never on the page. The
cannot growsentence is the test case: it is important and it is three lines long, so it is a badge. - Plain words on the surface, jargon underneath. "wall", not
Enforced. "still costs", nottombstoned_bytes. The precise name appears in the detail. - 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. - Sort by who needs attention, never alphabetically. The tenant at their cap is the first row.
- Outcomes, not steps.
Sold bob 90 GiB · 1 980.00 SEKis 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 undergunnar/<t>,gunnar/@<company>/<ns>andholger/<t>. It dials no estate;--estatesand thex-monetize-estateheader 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 inmonetize_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.Historykeeps 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.Capacitysays 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, orfree). - 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.