# MGM as a Microgrid OS: GUI contract v0 and the Gridtop default GUI

**To:** Aaron · **Date:** 6 November 2026 · **Status:** final proposal, revised after three reviews (security and invariants; a third-party GUI author's walkthrough; operator experience and codebase feasibility). Nothing here has been built, committed or posted.

**Basis:** branch `feat/operations-savings` at d8955eb plus its uncommitted grid-tariff and savings-settings work (`main` is at 6a3c817); cordis 4.0.0-rc.12, Next.js 16.4.1, React 19.2.3 (`@types/react` 19.2.14), zod 4.3.6, TypeScript 5.9.3. The contract in §4.1 is the file `contract.ts`. It was type-checked standalone with `tsc` in strict mode together with the billing, SMS-reminders and `gui-lite` examples in §5 and twelve negative cases that the compiler must reject: 0 errors. Example names and figures in screens (Kasana Energy Co-op, Kasana Central, Nakato House, UGX 4,216,800) are illustrative. Section 11 lists what Aaron decides; Appendix B records how the reviews were resolved.

---

## 1. The vision

MGM becomes a Microgrid OS: one long-lived Cordis root on which every capability is a plugin (community management, metering, billing, payments, and whatever a partner adds), and so are the two things people actually touch, the GUI and the assistant. Capability plugins describe what exists and what a person may do, as typed, server-formatted views and actions behind released, gated routes, while a GUI plugin decides only how that looks and is laid out, so a desk laptop, a 2G field phone and a later conversation-first interface can each have their own GUI without any capability changing. The assistant is one more optional plugin that reads views a capability marks as readable and proposes actions a person could take, and nothing it prepares becomes a record until a person submits it through the same route and the same host-owned confirmation step.

## 2. Design principles

1. **Describe, don't draw.** Capability plugins contribute data, never pixels, so each plugin and each GUI is written once (N+M, not N×M).
2. **The server and the host hold every rule.** Role, gate, release, RLS and the close digest are enforced by routes and the database. Confirmation steps are rendered by host-owned, code-owned components that accept only real input; a GUI frames them and has no way to submit without them.
3. **URLs belong to capabilities and the host.** GUIs own no paths and no current URL moves, so SMS and invoice links, bookmarks, guides and assistant hand-offs survive any GUI switch.
4. **Deterministic first, AI optional.** "Needs you", ⌘K, signals and suggestions work with the assistant off; the assistant adds explanation and drafting, never authority.
5. **Proposed is dashed, recorded is solid.** Anything a watcher or the assistant prepared looks like a draft until a person submits it.
6. **A quiet board.** Colour means abnormal or flowing, every live value shows its age, derived values are marked, so the screen reads in sunlight and can be trusted at night.
7. **Design for a 1366×768 laptop and a $40 Android on 3G.** At most two bands above content, 44px touch targets, and byte budgets that the `Build` job enforces.
8. **Small PRs that ship and delete.** Each phase lands in reviewable slices and removes what it replaces, and no page loses navigation before it migrates.

## 3. Architecture at a glance

| Layer | Holds | Never holds |
|---|---|---|
| **Host** (core, code-owned where marked): middleware, the `(gui)` layout and catch-all page, `GET /api/gui/{view,search,signals}` and `POST /api/gui/prepare` (classified as a read), the action host (forms, the four confirmation levels, `invoke`), auth pages, the client kit | Session checks, the manifest builder, the view resolver and dispatcher, the strict parse of every boundary output, targeted-action merge, confirmation steps | Capability logic |
| **Capability plugins**: organization directory, community management, metering, billing, payments, partner plugins | Routes, body schemas (`schemas.ts`), composers, capabilities, and one server-only *surface contribution*: nav, views, actions, signals, status, search, own entity kinds | React, CSS |
| **GUI plugin**: a reviewed, compiled-in React package plus metadata | `Shell`, a generic `View`, optional bespoke renderers and forms, an `Overlay` frame, a stylesheet declared in its metadata | `fetch`, `process.env`, Supabase clients, composers, Server Actions, URLs, `next/*`, confirmation steps |
| **Assistant plugin**: optional, off by default | Threads, events and pseudonym tables written only through a SECURITY DEFINER function, a gated step route, a feed view, a status item, tools derived from the contract | A client of its own, a write path, any human-only action, any view not marked `agent: "read"` |
| **Provider plugins** | OpenEMS metering, fixture metering (only under an installation setting), Pesapal, OpenAI-compatible and Anthropic LLM adapters | Anything a GUI or the assistant reaches directly |

```
src/lib/gui/contract.ts            client-safe contract and the GuiKit interface (Part A of §4.1)      code-owned
src/lib/gui/surface.ts             server-only contribution types (Part B of §4.1)                      code-owned
src/lib/gui/fmt.ts                 server Value builders; StatusChip's status maps move here
src/lib/gui/host/                  manifest, resolver, dispatcher, strict parse, merge, action host,
                                   ConfirmStep, invoke, the kit's client runtime                       code-owned
src/lib/gui/kit/index.ts           the kit entry GUIs import as "@/lib/gui/kit" (satisfies GuiKit): runtime from host/
src/lib/gui/kit/                   presentational kit components: KitView, RawView, ActionForm, SignOut
src/lib/gui/conformance/           describeGuiConformance(), fixtures, the GUI × view matrix          code-owned
src/lib/runtime/root.ts            + SurfacePlugin (provides mgmSurface), *-surface plugins, GUI install plugins
src/lib/runtime/gate.ts            GateTarget gains household and paymentAttempt (P1)
src/lib/<capability>/schemas.ts    one body schema per action; the route and the surface both import it
src/lib/<capability>/surface.ts    each capability's contribution
src/lib/assistant/                 the assistant plugin; providers/ holds the LLM adapters             code-owned
src/gui/meta.ts, installed.ts      GUI metadata (read at boot, no React) and the React half (host pages only)  code-owned
src/gui/gridtop/                   the default GUI package
src/gui/lite/                      the low-bandwidth reference GUI (P3)
src/app/(gui)/layout.tsx           host layout: getRequestUserAccess(), redirects, manifest, <ActionHost><gui.Shell>
src/app/(gui)/page.tsx             "/" through the same resolver
src/app/(gui)/[...path]/page.tsx   host catch-all: its own getRequestUserAccess(), then the resolver
src/app/api/gui/*/route.ts         view, search, signals (GET) and prepare (POST, read), owned by organization-directory
src/app/payment/callback/route.ts  static HTML receipt (replaces the page; same URL)
```

```mermaid
flowchart LR
  subgraph B["Browser"]
    G["GUI package<br/>Shell · View · renderers · forms · Overlay"]
    K["Host kit<br/>act · open · loadView · search · Link"]
    AH["Action host (code-owned)<br/>form · ConfirmStep · invoke"]
  end
  subgraph H["Next.js host"]
    MW["middleware<br/>released routes · session refresh · x-mgm-path"]
    L["(gui)/layout<br/>getRequestUserAccess · manifest · initial view"]
    P["(gui)/[...path]<br/>getRequestUserAccess · resolver"]
    GA["/api/gui/view · search · signals · prepare<br/>dispatcher: released · owned · gated · no-store"]
    R["existing write routes<br/>gateRoute · compose*"]
  end
  subgraph RT["Cordis root, one per server process"]
    S["mgmSurface<br/>contribute (parsed at boot) · install"]
    CG["mgmCatalog · mgmGate"]
    REG["meteringProviders · paymentProviderRegistry · llmProviders"]
  end
  CAP["Capability plugins<br/>community · metering · billing · payments · sms"]
  AS["assistant plugin<br/>off by default"]
  PR["Provider plugins"]
  GM["GUI metadata<br/>gridtop · lite"]
  DB[("Supabase Postgres<br/>RLS")]

  G --> K
  K --> AH
  K -->|"GET"| GA
  AH -->|"POST prepare"| GA
  AH -->|"POST · PUT · PATCH · DELETE"| MW
  MW --> R
  L -->|"renders Shell"| G
  P -->|"renders View"| G
  CAP -->|"ctx.effect contribute"| S
  AS -->|"feed · status · tools"| S
  GM -->|"ctx.effect install"| S
  P --> S
  GA --> S
  S -->|"loaders compose read-only"| CAP
  R --> CAP
  CAP --> CG
  CAP --> REG
  PR --> REG
  CAP --> DB
```

**A page view** (`/microgrids/<id>/billing/<periodId>`):
1. Middleware checks `isReleasedRoute`, refreshes the session and forwards the path as `x-mgm-path`.
2. The host layout calls `getRequestUserAccess()` and applies today's `/login`, `/setup` and `/no-access` redirects. It picks the current organization (the path's entity, else a host-validated `?org=`, else the last-used cookie, else the first) and builds the manifest for that organization only: nav, status items and places, no signals, `Cache-Control: no-store`. It resolves an `InitialView` (title, trail, tabs, scope) from the path, so the first server render is already right, and renders `<KitProvider><ActionHost><gui.Shell manifest initial>`.
3. The catch-all page calls `getRequestUserAccess()` on its own path and answers 404 for any `/api/*` path. It matches a contributed view by path, resolves each segment by its declared kind (`resolveTargetOrganization` over the extended `GateTarget`, or a plugin's own `organizationOf`), and answers 404 when an entity resolves to no visible organization or when two segments resolve to different organizations. A loader never runs with a null organization unless its view is installation-scoped.
4. The loader runs with its own RLS client for the caller's session and the already-checked user, composing read-only (a facade with read methods only) and disposing in `finally`.
5. The host strict-parses the result: only declared cell, field, tile and series keys; the view's strict `ext` schema; nested embeds; links and hrefs (same-origin paths, released). It appends other plugins' actions that target the entity, labelled with the contributing plugin, resolves every `ActionRef`'s `enabled` and `reason` (role, gate, release, `available`), and sets a `notice` from the gate's state.
6. It renders `gui.renderers[view]` when the renderer reads the model's `ext` version, otherwise `gui.View`; a kind newer than the GUI's declared contract range goes to the host's `RawView`. A `ViewBoundary` publishes the model to the kit for `useView()`.
7. After first paint the Shell's tray fetches `GET /api/gui/signals`, where each watcher has its own time budget; a miss shows "not checked".

**An action** (Generate bills, Close period):
1. The GUI calls `kit.act(ref)`. That is the only write path a GUI has.
2. The action host calls `POST /api/gui/prepare` with the action id and params. The server answers with the spec for the current organization (method, path, input schema without bound or host-managed fields), refusing when the action's plugin is disabled or its route is not released.
3. If the action has person-entered fields, the host renders the GUI's bespoke form when its declared `reads` include the spec's `inputVersion` and the level is `none` or `confirm`, otherwise the kit's `ActionForm`, inside the GUI's `Overlay`.
4. The host's `ConfirmStep` runs the level: `confirm` shows a statement (for an external action, who is contacted); `typed` asks for the name; `ceremony` shows facts, warnings and unticked acknowledgements verbatim. Ticks and the final press are accepted only from trusted input events.
5. `invoke` takes method and path from the spec, encodes each param and matches it to its segment, attaches the confirmation and, for a submitted proposal, `x-mgm-proposal`. A 401 forces a hard navigation to `/login`.
6. The route runs as it does now: `getUser()` in the composer, the gate, RLS, the capability. For a close, the database recomputes the digest and the acknowledgement set inside the write.
7. The kit follows `ActionResult.href` softly, or refreshes the view and manifest; signals refresh only when the result says so.

**An assistant step** (P4):
1. The person types in ⌘K or the assistant tile; `kit.useFeed(href).send(text)` runs the feed's compose action with the text and the current view's href as context.
2. The step route gates `assistant` against the thread's organization, checks that the caller is the thread's author, composes afresh (so `getUser()` runs every step), makes one model call and at most three tool calls. Each tool call answers 403 unless it resolves to the thread's organization. Events are appended by a SECURITY DEFINER writer.
3. The result carries `next` while there is more to do; the kit keeps posting until done or until Stop.
4. A proposal arrives as a dashed `ActionRef` with `prefill` and `provenance`. "Review" calls `kit.act(ref)`: the normal form, the normal confirmation step, the normal route. The server records a `submitted` event only after it checks the proposal.

## 4. The GUI capability contract v0

### 4.1 The types

`contract.ts` below is the whole contract: Part A (about 300 non-blank lines) is client-safe and is all a GUI author reads; Part B (about 110) is what a capability author writes against. A GUI built on the kit's `KitView` touches about fifteen names: `GuiPlugin`, `GuiMeta`, `ShellProps`, `Manifest`, `OrgManifest`, `NavEntry`, `StatusItem`, `Signals`, `ViewState` and the kit's `useManifest`, `useView`, `useSignals`, `Link`, `navigate`, `SignOut`.

```ts
/**
 * MGM GUI contract v0 (experimental): standalone extract for review and type-checking.
 *
 *   Part A  →  src/lib/gui/contract.ts   client-safe types and two constants. All a GUI author reads.
 *   Part B  →  src/lib/gui/surface.ts    server-only ("server-only" import there). What a capability
 *                                        plugin contributes to the root `mgmSurface` service.
 *
 * The kit runtime (src/lib/gui/kit, src/lib/gui/host) is declared here as `GuiKit` and implemented with
 * `satisfies GuiKit`, so the kit's shape moves under the same semver as the types.
 *
 * Nothing here is a rule the server trusts. Role, gate, release, RLS, the confirmation levels and the
 * close digest are enforced by the host, the routes and the database; these types make the right thing
 * the easy thing, and `contribute()` re-checks every rule at boot (see Part B).
 */
import type { ComponentType, ReactNode } from "react";
import type { Context } from "cordis";
import type { ZodType } from "zod";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import type { MgmPluginName } from "@/lib/plugins/bundled";
import type { UserRoleRecord } from "@/lib/types/domain";

// ════════════════════════════════════════════════════════════════════════════════════════════════
// Part A: src/lib/gui/contract.ts (client-safe)
// ════════════════════════════════════════════════════════════════════════════════════════════════

/** Contract version (semver). A GUI declares the range it supports, e.g. "^0.1". */
export const GUI_CONTRACT = "0.1.0";

// ── Vocabulary ──────────────────────────────────────────────────────────────────────────────────

/** "<alias>.<name>": the alias is the contributing plugin's catalog alias ("billing", "org", "community"). */
export type Id = `${string}.${string}`;
/** A same-origin relative path ("/…", never "//…", no scheme). The host's parse rejects anything else. */
export type Href = `/${string}`;
/** The only external targets a view may carry: a WhatsApp chat, a call, an SMS. Nothing else leaves the origin. */
export type OutHref = `https://wa.me/${string}` | `tel:${string}` | `sms:${string}`;
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
/** Meaning, not colour. `accent` is the old "brand" tone (a label, never a state). */
export type Tone = "neutral" | "info" | "success" | "warning" | "danger" | "accent";
/** What a person does before a write. The host runs every level; a GUI only frames it (GuiPlugin.Overlay). */
export type Confirm = "none" | "confirm" | "typed" | "ceremony";
export type NavGroup = "operate" | "money" | "configure" | "system";
/** A fixed icon vocabulary, so every GUI can map names to its own set. Unknown names render as text only. */
export type Icon =
  | "today" | "live" | "sites" | "households" | "devices" | "readings" | "billing" | "payments"
  | "tariff" | "data-source" | "branding" | "accounts" | "plugins" | "users" | "profile"
  | "organizations" | "assistant" | "message" | "alert" | "download" | "share" | "contact" | "help";
export type CoreEntityKind =
  | "organization" | "community" | "microgrid" | "edge" | "device" | "household"
  | "rateSchedule" | "billingPeriod" | "lineItem" | "paymentAttempt";
/** Core kinds, or a plugin's own namespaced kind ("assistant.thread", "sms-reminders.send"). */
export type EntityKind = CoreEntityKind | `${string}.${string}`;
export interface EntityRef { kind: EntityKind; id: string; label: string; href?: Href }
export type EntityHit = EntityRef & { href: Href };
export type Trail = { label: string; href: Href }[];

// ── Values: formatted on the server, so a GUI may simply print `text` ────────────────────────────

type ValueMeta = {
  text: string;        // locale-, currency- and zone-formatted; in a bare column, the bare figure "4,216,800"
  derived?: true;      // computed rather than measured: mark it (≈, dashed)
  at?: string;         // ISO time measured; age it against Manifest.now, hatch it past staleAfterSeconds
  synthetic?: true;    // from the fixture metering provider: always labelled
};
export type Value = ValueMeta & (
  | { t: "text" } | { t: "empty" }
  | { t: "number"; v: number; unit?: string }
  | { t: "percent"; v: number }
  | { t: "money"; v: number; currency: string }      // v: integer minor units per ISO 4217 (UGX has none: shillings)
  | { t: "energy"; v: number; unit: "Wh" | "kWh" }
  | { t: "power"; v: number; unit: "W" | "kW" }
  | { t: "date"; v: string }                         // YYYY-MM-DD in the entity's zone
  | { t: "datetime"; v: string; tz: string }
  | { t: "status"; v: string; tone: Tone }           // tone from the shared status maps in fmt.ts
  | { t: "ref"; ref: EntityRef }
);
export type ValueType = Value["t"];

/** Server-built rich text: paragraphs and list items. De-pseudonymised and link-checked before it leaves the server. */
export type Inline =
  | string
  | { value: Value }                 // a grounded figure (from a loader's Value)
  | { link: Href; text: string }     // a released route, checked on the server
  | { unverified: string };          // a figure that matched no Value: render with an "unverified" mark
export type Rich = ({ p: Inline[] } | { li: Inline[] })[];

/** A GET the person opens, downloads or shares. Never a write; no confirmation. */
export interface LinkRef {
  id: Id; label: string; icon?: Icon;
  href: Href | OutHref;
  download?: { mime: string; filename: string };   // e.g. the bill PDF, the URA CSV
  share?: { title: string; text: string };         // a GUI may offer Web Share (files fetched ahead when canShare)
}

// ── Actions, as a GUI sees them ─────────────────────────────────────────────────────────────────

/** A control, resolved by the host for this person and entity. A GUI renders it and calls `kit.act(ref)`. */
export interface ActionRef {
  action: Id;
  params: Record<string, string>;  // route segments and bound body fields, filled by the host from the entity
  label: string;
  enabled: boolean; reason?: string;  // from role, gate, release and the action's `available`
  tone?: "danger";                    // typed or ceremony, delete or destructive
  from?: string;                      // set when another plugin contributed this control: "SMS reminders"
  prefill?: Json;                     // parsed against the input schema, writeOnly fields removed
  provenance?: { by: "assistant" | "watcher"; proposal?: string; at: string };  // present: render as proposed (dashed)
}
/** What a bespoke form needs. Fetched by the kit when the form opens, never shipped in the manifest. */
export interface ActionSpec {
  type: "spec"; id: Id; title: string; confirm: Confirm;
  inputVersion: number;   // a form declares the versions it reads; on a mismatch the kit uses its ActionForm
  input: Json;            // JSON Schema of the person-entered fields: bound fields removed, secrets marked writeOnly
}
export type ActionResult =
  | { type: "result"; ok: true; message?: string; href?: Href; next?: ActionRef; signals?: true }
  | { type: "result"; ok: false; status: number; code: string; message: string; field?: string };

// ── The five view kinds (generic over the action type: ActionRef for GUIs, ActionLink for loaders) ──

export interface Column { key: string; label: string; type: ValueType; priority: 1 | 2 | 3; copy?: "bare" }
export interface RowOf<A> {
  id: string; entity?: EntityRef; related?: EntityRef[]; href?: Href;
  cells: Record<string, Value>; actions: A[]; links?: LinkRef[];
}
export interface Filter { label: string; href: Href; count?: number; current: boolean }
export type AuthorKind = "person" | "assistant" | "system";
export type EmbedOf<A, V> =
  | ({ type: "view"; href: Href } & V)   // loaders give the href; the host loads, checks and parses the model
  | { type: "action"; ref: A }
  | { type: "values"; values: Value[] }
  | { type: "steps"; steps: { label: string; ms: number; ok: boolean }[] };
export interface FeedEntryOf<A, V> {
  id: string; at: string; author: { kind: AuthorKind; name: string }; text: Rich; embed?: EmbedOf<A, V>;
}
/** A free-text input bound to an action (the assistant's Ask). The kit sends text and the current view. */
export interface ComposeOf<A> { action: A; textField: string; contextField?: string; placeholder: string }
export interface BodiesOf<A, V> {
  collection: {
    columns: Column[]; rows: RowOf<A>[];
    totals?: Record<string, Value>;      // rendered below the rows, never as a row
    filters?: Filter[]; next?: Href;     // bulk selection is out of scope for v0
    empty: { title: string; text?: string; action?: A };  // at zero rows the empty state owns the create action
  };
  record: { sections: { title?: string; fields: { key: string; label: string; value: Value }[] }[] };
  metrics: {
    tiles: { key: string; label: string; value: Value; status?: Value; hint?: string }[];
    series?: { key: string; label: string; unit: string; points: [string, number | null][] }[];  // null is a gap
  };
  feed: { entries: FeedEntryOf<A, V>[]; compose?: ComposeOf<A>; stop?: A; busy?: boolean };
  flow: {
    steps: { id: string; title: string; state: "done" | "current" | "todo" | "blocked";
             detail?: string; href?: Href; action?: A }[];
  };
}
export type ViewKind = keyof BodiesOf<unknown, unknown>;

export interface ModelBase<K extends ViewKind, A, V> {
  type: "view"; view: Id; kind: K; body: BodiesOf<A, V>[K];
  title: string; subtitle?: string;
  org: string | null; entity?: EntityRef; trail: Trail;
  actions: A[]; links: LinkRef[];
  attached: { view: Id; title: string; href: Href; hint: "section" | "tab" | "tile" }[];  // the GUI decides
  notice?: { tone: Tone; text: string };                      // set by the host, e.g. from the gate's state
  live?: { refreshSeconds: number; staleAfterSeconds: number };
  ext?: { schema: `${Id}@${number}`; data: Json };           // only for a renderer that reads this version
}
export interface EmbeddedView { model: ViewModel }
export type ModelOf<K extends ViewKind> = ModelBase<K, ActionRef, EmbeddedView>;
export type ViewModel = { [K in ViewKind]: ModelOf<K> }[ViewKind];
/** The contract version each kind arrived in; the host gives a GUI only kinds its declared range covers. */
export const GUI_KINDS_SINCE: Record<ViewKind, string> = {
  collection: "0.1.0", record: "0.1.0", metrics: "0.1.0", feed: "0.1.0", flow: "0.1.0",
};

// ── What the Shell receives ─────────────────────────────────────────────────────────────────────

type SignalBase = {
  id: string; source: Id; severity: "info" | "warn" | "critical"; title: string;
  facts?: Value[]; entity?: EntityRef; href: Href; at: string; due?: string;
};
export type SignalOf<A> =
  | (SignalBase & { kind: "alarm" })
  | (SignalBase & { kind: "your-move" })                 // never carries an action: it links to its step
  | (SignalBase & { kind: "proposal"; action: A })       // the action carries provenance: render dashed
  | (SignalBase & { kind: "question" })                  // answered where `href` points (its thread)
  | (SignalBase & { kind: "info" });
export type Signal = SignalOf<ActionRef>;
/** Streamed after the first paint (GET /api/gui/signals), never part of the manifest. */
export interface Signals { type: "signals"; org: string; count: number; top: Signal[]; unchecked: Id[] }

export interface StatusItemOf<A> {
  id: Id; label: string; state: "off" | "idle" | "working" | "needs-you";
  open: Href;              // a feed or page to dock or link
  ask?: ComposeOf<A>;      // offered as "Ask" in a command palette
}
export type StatusItem = StatusItemOf<ActionRef>;
export interface NavEntry {
  id: Id; title: string; icon: Icon; group: NavGroup; order: number;
  href: Href;              // a pattern; the host fills [segments] by kind through ViewState.href
  needs: CoreEntityKind[]; // context the pattern needs; without it a GUI offers ViewState.choices
  legacy?: true;           // migration period: a released page with no contributed view yet
}
export interface OrgManifest {
  id: string; name: string; href: Href; timezone: string | null;
  nav: NavEntry[]; status: StatusItem[];
  places: EntityRef[];     // the organization's communities and microgrids, for context pickers (capped)
}
/** Built once per hard load for the current organization only. Cache-Control: no-store. */
export interface Manifest {
  type: "manifest"; contract: string; release: string; locale: string;
  now: string;             // server time, so ages are right on phones with wrong clocks
  gui: string;
  user: { name: string | null; email: string };
  org: OrgManifest;
  orgs: { id: string; name: string; href: Href }[];  // the switcher; other organizations load on switch
}
/** Resolved by the host from the request path, so the first server render is already right. */
export interface InitialView {
  view: Id | null; title: string; trail: Trail;
  tabs: { title: string; href: Href; current: boolean }[];
  scope: Partial<Record<CoreEntityKind, EntityRef>>;
}
export interface ViewState extends InitialView {
  model: ViewModel | null;                    // null on a legacy page
  org: OrgManifest;
  /** Fills the pattern by kind from scope (or `pick`); null when a needed kind is missing. */
  href(nav: NavEntry, pick?: Partial<Record<CoreEntityKind, EntityRef>>): Href | null;
  choices(kind: CoreEntityKind): EntityRef[]; // candidates for a context picker
}

// ── The kit: the only I/O a GUI has ─────────────────────────────────────────────────────────────

export interface LiveState {
  model: ViewModel;
  ageSeconds(value: Value): number | null;
  stale(value: Value): boolean;
  polling: "on" | "slow" | "paused";   // slow on Save-Data, effectiveType 2g/3g or saveData; paused when hidden
}
export interface FeedState {
  model: ModelOf<"feed"> | null; busy: boolean;
  send(text: string): Promise<ActionResult>;  // runs compose, then follows ActionResult.next until done or stopped
  stop(): void;
}
export interface LinkProps { href: Href; children: ReactNode; className?: string; current?: boolean }
export interface GuiKit {
  useManifest(): Manifest;
  useView(): ViewState;
  useSignals(): Signals | null;
  useLive(model: ViewModel): LiveState;
  useFeed(href: Href): FeedState;
  loadView(href: Href): Promise<ViewModel | ActionResult>;
  search(query: string, prefix: "@" | "#"): Promise<EntityHit[]>;
  Link: ComponentType<LinkProps>;
  navigate(href: Href): void;
  refresh(): void;
  usePathname(): string;
  /** GUI-owned interface state (selection, layout). `ui.*` parameters never reach a loader. */
  useUiParam(name: `ui.${string}`): [string | null, (value: string | null) => void];
  /** The only way to write: the host runs the form, the confirmation level and the request.
   *  Resolves { ok: false, status: 0, code: "cancelled" } when the person cancels. */
  act(ref: ActionRef, options?: { initial?: Json }): Promise<ActionResult>;
  open(link: LinkRef): Promise<void>;
  KitView: SyncComponent<ViewProps>;   // the generic renderer of all five kinds; satisfies GuiPlugin.View
  RawView: SyncComponent<ViewProps>;   // the host fallback for a kind a GUI does not know
  SignOut: ComponentType<{ className?: string }>;
}

// ── What a GUI plugin implements ────────────────────────────────────────────────────────────────

/** A component that renders synchronously, so it works in the browser, in jsdom and inside embeds. */
export type SyncNode = Exclude<ReactNode, Promise<unknown>>;
export type SyncComponent<P> = (props: P) => SyncNode;
type Sync<P> = SyncComponent<P>;
/** Shell and PublicFrame may be server components. */
type MaybeServer<P> = (props: P) => ReactNode | Promise<ReactNode>;

export interface ShellProps { manifest: Manifest; initial: InitialView; children: ReactNode }
export interface ViewProps { model: ViewModel }
export interface FormProps {
  spec: ActionSpec; actionRef: ActionRef; model: ViewModel | null;
  initial: Json | null; busy: boolean; error: { message: string; field?: string } | null;
  submit(body: Json): void;   // the kit then runs the confirmation level and the request
  cancel(): void;
}
export interface OverlayProps {
  title: string; tone: "neutral" | "danger"; role: "form" | "confirm";
  onDismiss(): void;
  children: ReactNode;        // host-rendered content; the GUI frames it (dialog, sheet, page) and never alters it
}
export type PublicPage =
  | "login" | "forgot-password" | "reset-password" | "accept-invite" | "no-access" | "setup";

/** The metadata half: src/gui/meta.ts. Read at boot without importing React. */
export interface GuiMeta {
  name: string;                    // one identifier: the map key, the Cordis plugin name and the MGM_GUI value
  title: string; version: string;
  contract: string;                // a semver range over GUI_CONTRACT, e.g. "^0.1"
  styles: string;                  // the package's stylesheet source; the host links only the active GUI's build
  dependencies: readonly string[]; // npm packages it imports, each on the reviewed list in gui-boundary.test.ts
}
/** The React half: src/gui/installed.ts. Imported only by host pages; importable from client code. */
export interface GuiPlugin extends GuiMeta {
  Shell: MaybeServer<ShellProps>;                    // MUST: persistent layout
  View: Sync<ViewProps>;                             // MUST: the five kinds (re-exporting kit.KitView counts)
  renderers?: Partial<Record<Id, { reads: readonly number[]; Component: Sync<ViewProps> }>>;  // MAY: by view id
  forms?: Partial<Record<Id, { reads: readonly number[]; Component: Sync<FormProps> }>>;      // MAY: none/confirm only
  Overlay?: Sync<OverlayProps>;                      // MAY: frames the host's forms and confirmation steps
  Loading?: Sync<Record<string, never>>;             // MAY: otherwise the host's neutral skeleton
  PublicFrame?: MaybeServer<{ page: PublicPage; children: ReactNode }>;  // MAY: server component, no client imports
}

/** Host-owned shapes. GUIs never receive these; listed so the host and capabilities share one type. */
export interface Ceremony {
  type: "ceremony";
  statement: string;
  facts: { label: string; value: Value }[];
  warnings: string[];
  acknowledgements: { id: string; label: string }[];  // rendered verbatim, unticked; the server recomputes the set
  typed?: string;                                      // the name a person types; compared again on the server
  digest?: string;                                     // recomputed by the server inside the write
}
export interface Confirmation { digest?: string; acknowledged: string[]; typed?: string }

// ════════════════════════════════════════════════════════════════════════════════════════════════
// Part B: src/lib/gui/surface.ts (server-only)
// ════════════════════════════════════════════════════════════════════════════════════════════════

/** Server Value builders (src/lib/gui/fmt.ts). The status maps move here from StatusChip. */
export interface Fmt {
  text(s: string): Value;
  empty(): Value;
  number(v: number, unit?: string): Value;
  money(minor: number, currency: string, o?: { bare?: true }): Value;
  energy(wh: number, o?: { bare?: true; at?: string; derived?: true; synthetic?: true }): Value;
  power(w: number, o?: { at?: string; derived?: true; synthetic?: true }): Value;
  percent(v: number, o?: { at?: string; synthetic?: true }): Value;
  date(isoDate: string): Value;
  datetime(iso: string, tz: string): Value;
  status(map: string, v: string): Value;
  ref(r: EntityRef): Value;
}

/** One per loader call, signal, search, prepare or tool call. */
export interface SurfaceRequest {
  supabase: SupabaseClient;   // this call's own client for the caller's session (RLS), never the service role
  user: User;                 // already checked with getUser() on this request
  roles: UserRoleRecord[];
  organizationId: string;     // from the entity, or the host-validated current organization; never null here
  params: Record<string, string>;
  search: URLSearchParams;    // `ui.*` parameters removed
  locale: string; now: Date; fmt: Fmt;
}
/** Installation-scoped views (the super admin's organization list) get no organization and may not compose. */
export type InstallationRequest = Omit<SurfaceRequest, "organizationId"> & { organizationId: null };
export type Loaded<T> =
  | { ok: true; model: T }
  | { ok: false; status: 403 | 404 | 409 | 500 | 503; message: string };

/** What a loader puts where a GUI will see an ActionRef. The host resolves label, enabled, reason, tone and from. */
export type ActionLink = Pick<ActionRef, "action" | "params" | "prefill" | "provenance"> & { label?: string };
type HostFilled = "type" | "view" | "kind" | "org" | "trail" | "attached" | "notice" | "live" | "links";
export type LoadedModel<K extends ViewKind> =
  Omit<ModelBase<K, ActionLink, unknown>, HostFilled> & { links?: LinkRef[] };

interface ViewDefBase<K extends ViewKind> {
  id: Id; kind: K;
  path: string;                            // a page route this plugin owns in bundled.ts and releases
  params: Record<string, EntityKind>;      // which segment holds which kind: { id: "microgrid", periodId: "billingPeriod" }
  entity?: string;                         // the segment naming this view's own entity
  attach?: { to: EntityKind; order: number; hint: "section" | "tab" | "tile" };
  cost: "db" | "vendor";                   // vendor: gated like a write, polled sparingly
  agent?: "read";                          // deny by default: only this makes the view an assistant read tool
  live?: { refreshSeconds: number; staleAfterSeconds: number };
  declares: { keys: readonly string[]; prefixes?: readonly string[] };  // every cell, field, tile and series key
  ext?: { schema: `${Id}@${number}`; zod: ZodType };                     // strict objects all the way down
  examples: readonly ModelOf<K>[];         // fixtures for conformance and the GUI × view matrix
}
export type ViewDefOf<K extends ViewKind> = ViewDefBase<K> & (
  | { scope?: "organization"; load(req: SurfaceRequest): Promise<Loaded<LoadedModel<K>>> }
  | { scope: "installation"; load(req: InstallationRequest): Promise<Loaded<LoadedModel<K>>> }
);
export type ViewDef = { [K in ViewKind]: ViewDefOf<K> }[ViewKind];

export type Prepare = (
  req: SurfaceRequest,
  input: { params: Record<string, string>; body: unknown },
) => Promise<Loaded<Ceremony>>;
type ActionBase = {
  id: Id; title: string;
  target: EntityKind;                     // the entity this action is offered on
  input: ZodType;                         // from src/lib/<capability>/schemas.ts, the schema the route parses with
  inputVersion: number;
  path: string;                           // an existing route.ts exporting `method`, owned and released
  bind: { path?: Record<string, EntityKind>; body?: Record<string, EntityKind> };  // filled by the host, hidden in forms
  available?(req: SurfaceRequest, ids: string[]): Promise<Record<string, true | string>>;  // true or a reason
};
type WriteMethod = "POST" | "PUT" | "PATCH";
type LightLevel =
  | { risk: "write" | "money"; confirm: "none" | "confirm"; method: WriteMethod; prepare?: Prepare }
  | { risk: "external"; confirm: "confirm"; method: WriteMethod; prepare: Prepare };  // shows who is contacted
/** The only kind the assistant may turn into a propose tool. */
export type ProposableAction = ActionBase & LightLevel & { humanOnly: false };
export type HumanAction = ActionBase & LightLevel & { humanOnly: true };
/** Typed and ceremony actions, and every DELETE or destructive action: human-only, prepared, never a tool. */
export type GuardedAction = ActionBase & {
  humanOnly: true; confirm: "typed" | "ceremony";
  risk: "write" | "money" | "external" | "destructive";
  method: WriteMethod | "DELETE";
  prepare: Prepare;
};
export type ActionDef = ProposableAction | HumanAction | GuardedAction;

type NavDef = { id: Id; title: string; icon: Icon; group: NavGroup; order: number }
  & ({ view: Id } | { href: string; needs: CoreEntityKind[]; legacy: true });
type SignalOut = SignalOf<ActionLink> extends infer S ? (S extends unknown ? Omit<S, "source"> : never) : never;

export interface Contribution {
  plugin: MgmPluginName;
  nav?: NavDef[];
  views?: ViewDef[];
  actions?: ActionDef[];
  signals?: { id: Id; budgetMs?: number; load(req: SurfaceRequest): Promise<SignalOut[]> }[];
  status?: { id: Id; load(req: SurfaceRequest): Promise<Omit<StatusItemOf<ActionLink>, "id"> | null> }[];
  search?: { prefix: "@" | "#"; entity: EntityKind; find(req: SurfaceRequest, q: string): Promise<EntityHit[]> }[];
  /** A plugin's own entity kinds and how each resolves to its organization (null: not visible → 404). */
  entities?: {
    kind: `${string}.${string}`;
    organizationOf(req: InstallationRequest, id: string): Promise<string | null>;
  }[];
}

/** The one root service the host provides. */
export interface MgmSurface {
  /** Capability plugins, inside ctx.effect. Zod-parses the contribution and throws at boot on any rule it breaks. */
  contribute(c: Contribution): () => void;
  /** One per compiled-in GUI, inside ctx.effect. Throws when the GUI's range excludes GUI_CONTRACT. */
  install(gui: GuiMeta): () => void;
}

declare module "cordis" {
  interface Context { mgmSurface: MgmSurface }
}
/** The root context type with `mgmSurface`, for the host's install and contribute plugins. */
export type SurfaceContext = Context;
```

**What the compiler rejects** (the twelve negative cases in the type-check): a proposable `DELETE` or destructive action; a ceremony without `prepare`; a typed action that is not human-only; an external action without `prepare` or below `confirm`; a `DELETE` below `typed`; a loader whose body does not match its view's kind; an async `View`; an absolute `Href`; a "your move" signal carrying an action; an unknown key in a contribution. **What it cannot reject** is a `humanOnly` widened to `boolean`, which TypeScript spreads across the union. So the types are a first line only: `contribute()` zod-parses every contribution at boot and throws on every rule in §4.6, and the tool builder re-filters at run time.

### 4.2 What a capability-plugin author writes

1. **Catalog entries** in `bundled.ts` (code-owned): routes, pages with the entity kind of each segment, and a short alias for ids (`org` for organization-directory, `community` for community-management; other plugins use their name). A released-routes entry for each new path; a method-aware entry for each `DELETE`, recorded as a release decision in the same PR.
2. **`schemas.ts`**: one zod body schema per action. The route parses with it in the same PR, so the assistant's proposal validation and the route can never disagree. An action without a schema cannot be contributed.
3. **`surface.ts`**: a `Contribution`. Views name their path, the kind of each segment, the keys they emit (`declares`), cost, an optional `agent: "read"`, fixtures and a loader. Actions name the route, how the entity binds to path and body (`bind`), the schema and its version, a risk, a required `humanOnly`, a confirmation level and, where needed, `prepare`. Plus deterministic signals, optional status, search and the plugin's own entity kinds.
4. **A five-line platform plugin** calling `ctx.effect(() => ctx.mgmSurface.contribute(SURFACE))`, shaped like `metering/providers-plugin.ts`. Unloading it removes everything it added.
5. **Optionally an `ext` schema** (`billing.period-review@1`), strict objects all the way down, for bespoke renderers; a changed shape becomes `@2`.
6. **Run the tests**: `gui-surface.test.ts` and the GUI × view matrix tell the author what is missing.

No React, no CSS, and no change to any GUI.

### 4.3 What a GUI-plugin author must and may implement

| | Must | May |
|---|---|---|
| Layout | `Shell`: an organization switcher when there is more than one, nav by group with a context picker when a needed entity is missing (`ViewState.href` and `choices`), the trail, the Needs-you count, every status item, `kit.SignOut`, a `main` landmark | Docking, tiles, palette, keyboard map, PWA chrome |
| Content | `View` for the five kinds: print every `Value.text`, honour `copy: "bare"` and `priority`, render every `ActionRef` as a control or show its `reason`, mark provenance as "Proposed", render links, let the empty state own the create action, totals below rows, show notices. Re-exporting `kit.KitView` satisfies this | Bespoke `renderers` by view id, declaring the `ext` versions they read |
| Actions | Call `kit.act(ref)` for writes and `kit.open(link)` for links, nothing else | Bespoke `forms` by action id for `none` and `confirm` actions, declaring the input versions they read; an `Overlay` frame (dialog, sheet or page) for the host's forms and confirmation steps |
| Unknowns, auth | Nothing: the host's `RawView` takes kinds newer than the GUI and the host owns sign-in | `PublicFrame` around auth pages, as a server component with no client imports; a `Loading` skeleton |
| Tests | `describeGuiConformance(gui)` in a file the `gui` Vitest project collects | |

**Imports.** A GUI package may import `@/lib/gui/contract`, `@/lib/gui/kit`, `react`, `react-dom`, its own files, and npm packages on the reviewed list kept in the code-owned `gui-boundary.test.ts` (for Gridtop: `lucide-react`, `cmdk`, `@radix-ui/*`, `class-variance-authority`, `clsx`, `tailwind-merge`, `@tanstack/react-virtual`), which it also lists in `GuiMeta.dependencies`. It imports no `next/*` (the kit provides `Link`, `navigate`, `refresh`, `usePathname`), no `next/headers`, no `server-only`, and no CSS. Gridtop copies the shadcn primitives it uses into its own package as each page migrates, replacing `next/link` with `kit.Link`; legacy pages keep `src/components/ui` until they are deleted. The boundary rests on review plus the scan, with a CSP as defence in depth (§4.4); it is not process isolation.

**Registration.** `src/gui/<name>/meta.ts` exports its `GuiMeta`; `src/gui/meta.ts` maps names to metadata and is what `instrumentation.ts` and the GUI install plugins read, with no React. `src/gui/installed.ts` maps the same names to `GuiPlugin` objects and is imported only by host pages. One identifier (`gridtop`, `lite`) is the map key, `GuiMeta.name`, the Cordis plugin name and the `MGM_GUI` value; `installed-guis.test.ts` asserts the two maps agree. The `GuiPlugin` module has no `"use client"` and no CSS import, so it is importable from server and client code; its components may be client components in their own files. `View`, `renderers` and `forms` render synchronously, because they also render in the browser (the inspector, tiles, polling, feed embeds) and in jsdom; only `Shell` and `PublicFrame` may be server components. The kit renders an embedded view through the active GUI's renderers and `View`, so bespoke renderers apply inside feeds and tiles too.

### 4.4 Hosting

- **Persistent shell.** `(dashboard)` becomes `(gui)` (route groups do not affect URLs). The rename touches the middleware comment about the layout's `getUser()`, six `ENTRIES` keys in `service-role-session-check.test.ts`, `no-access/page.tsx`, `billing/audit-log-fetch.ts` and `microgrid-links.test.ts`, all in P1. Each page migrates by deleting its `page.tsx` so the catch-all takes the path; `(gui)/page.tsx` serves `/` through the same resolver.
- **The migration period.** Until a page migrates it stays a legacy page inside the shell. Its catalog entry contributes a nav entry with `legacy: true` and an `href` to its released path, and its default export is wrapped in the host's `legacyPage()`, which gives the Shell its trail and tabs from the catalog's segment kinds. Each per-page sub-nav, `TabNav` and `HierarchyNav` use is deleted only in the PR that migrates that page, so nobody loses navigation in between.
- **Selecting the GUI.** `MGM_GUI` is read at request time (ADR-003) from `src/gui/meta.ts`; unset means `gridtop`. `instrumentation.ts` checks it at server start: an unknown name, or a GUI whose range excludes `GUI_CONTRACT`, rejects `register()` with an error naming the variable and both versions. That takes down every route of the process, including `/p/<slug>`, `/pay`, the payments IPN and `/payment/callback`. A range mismatch already fails CI, so in practice only a mistyped `MGM_GUI` triggers it; release verification (`docs/releasing.md`) and #55's guides therefore check `MGM_GUI` before a deploy is promoted.
- **CSS.** A GUI declares its stylesheet source in `GuiMeta.styles`. A prebuild step compiles each installed GUI's stylesheet with Tailwind's Node API into a hashed static file, and the host layout links only the active GUI's. Gridtop's tokens move into its package. `globals.css` shrinks to the host's auth pages and the legacy pages, and goes when the last legacy page does. `gui-boundary.test.ts` bans CSS imports in GUI packages.
- **Intent URLs.** `?action=<id>&on=<kind>:<id>` on any view URL makes the host's action host open that action's form or confirmation for that entity, in every GUI. The host resolves it on load (the action exists, the entity is in the same organization, `enabled` holds). Intent URLs never carry prefill or a confirmation. "Your move" items, ⌘K commands and assistant hand-offs use them, and Android Back closes the step because it is a history entry.
- **Interface state.** `ui.*` query parameters belong to the GUI (inspector selection, tile layout), are stripped before any loader sees them, and survive pop-out.
- **Root views and the current organization.** Views whose path has no entity (`/`, `/payments`, `/assistant`) take the current organization from a host-validated `?org=`, which `ViewState.href` always writes, so links are shareable. Installation-scoped views (the super admin's organization list) get no organization and may not compose.
- **Headers.** `/api/gui/*` and the manifest are `no-store`. A CSP for the `(gui)` pages adds `connect-src 'self'` plus the runtime Supabase URL, `form-action 'self'` and `img-src 'self' data: blob:` as defence in depth behind the boundary scan.

### 4.5 Versioning

- `GUI_CONTRACT` is semver and GUIs declare a caret range. Through `0.x` the contract is experimental: a minor bump may change shapes, a patch only adds optional fields, kinds or values. Every compiled-in GUI runs conformance in the same PR.
- **Kinds carry the version they arrived in** (`GUI_KINDS_SINCE`), so the host knows which kinds a GUI can render and sends newer ones to `RawView`.
- **Renderers and forms declare what they read.** A renderer lists the `ext` versions it reads; a form lists the input versions it reads. On a mismatch the host falls back to the GUI's `View` or the kit's `ActionForm`, so a changed tier body cannot break Gridtop's tier editor silently.
- **Unknowns are read cautiously:** an unknown value type prints `text`; an unknown icon prints its title; an unknown nav group becomes system; an unknown signal kind becomes info. Risk never reaches a GUI: the host folds it into the confirmation level and the `tone`.
- **1.0 freezes** when Gridtop and `gui-lite` both pass conformance and the matrix; from then on minors add and majors break.

### 4.6 Conformance kit and guard tests

`vitest.config.ts` gains a `gui` jsdom project covering `src/gui/**` and `src/lib/gui/**/*.test.tsx`, and `installed-guis.test.ts` fails if any installed GUI has no conformance file that project collects. A GUI author adds one line, `describeGuiConformance(LiteGui)`, which renders the GUI against host-owned fixtures with a recording kit stub and checks:

1. **Version:** `GUI_CONTRACT` satisfies the declared range; metadata matches `meta.ts`.
2. **Manifests:** one organization; two organizations (the second is reachable through the switcher); billing off; an empty organization; an unknown `acme.tickets` plugin; legacy nav entries; a nav entry whose context is missing (a picker appears and leads to the filled href). Every nav entry and status item is reachable.
3. **Kinds and values:** all five kinds and every `Value` type. Every `text` appears; bare columns print bare figures with the unit only in the header; totals sit below the rows; an empty collection shows its action and no switcher; notices show; disabled actions show their reason and no live control; download and contact links render.
4. **Targeted actions:** a synthetic `acme.poke` action targeted at every entity kind in the fixtures is reachable on every row and view, in the generic `View` and in every bespoke renderer.
5. **Proposals and signals:** an `ActionRef` with provenance carries a visible, accessible "Proposed" marker; a "your move" signal renders no submit control.
6. **Writes:** every control calls `kit.act` with the exact `ActionRef` it was given; nothing calls `act` on mount.
7. **Unknowns:** a kind newer than the GUI's range reaches `RawView`.
8. **Accessibility basics:** accessible names on every control, `nav` and `main` landmarks, `aria-current` rather than `role="tab"` on route links, no positive `tabindex`.

**The matrix test** renders every contributed view's examples in every installed GUI with `acme.poke` injected, so a capability added next year fails CI if any GUI cannot show it.

**Host tests** (code-owned, `src/lib/gui/host`): every core entity kind resolves to its organization, and to none when not visible (database-backed); acknowledgements render verbatim and unticked; Confirm stays off until every box is ticked and the typed name matches; untrusted (synthetic) events are ignored; nothing submits on mount; `invoke` sends the digest and acknowledgement ids; the dispatcher refuses unreleased, unowned or `/api/*` hrefs and gates vendor views; `prepare` refuses a disabled plugin.

**Guard tests:**
- **`gui-boundary.test.ts`:** under `src/gui/**`, only the import allowlist above; no `fetch`, `XMLHttpRequest`, `EventSource`, `WebSocket` or `sendBeacon`; no `process.env`; no `"use server"`; no `<form action>` or `formAction`; no `dangerouslySetInnerHTML`; no external URL in `src`, `href`, `srcset` or CSS `url()`; no `new Image()`; no `import()` of a URL; no `window.open` without `noopener`; no `navigator.serviceWorker.register`; no CSS imports; `PublicFrame` modules import nothing client-side.
- **`gui-surface.test.ts`**, in the style of `route-surface.test.ts`:
  - Ids carry their plugin's alias; view paths are owned and released; every segment has a kind.
  - Every action path is an existing `route.ts` exporting that method, owned, released (method-aware for `DELETE`), and gated, or exempt with a `PaymentExemption`; a proposable action on an exempt route is allowed only when the exemption's `reaches` method stays available while the plugin is disabled (`refreshPaymentStatus`).
  - Every action's `input` comes from a `schemas.ts` module the route also imports.
  - `contribute()` rules: `typed` or `ceremony` requires `humanOnly: true` and `prepare`; `DELETE` or `destructive` requires `humanOnly: true` and at least `typed`; `external` requires `prepare` and at least `confirm`; unknown keys and values are refused. An unknown risk is refused at boot.
  - `declares` keys are free of secret-like names; `ext` schemas are strict objects all the way down (no `any`, `unknown`, loose, passthrough, catchall or record of unknown); examples parse.
  - Vendor loaders call a `COMPOSERS` composer without a `SKIPS_PLUGIN_CHECK` option; loaders, signals, search and `prepare` call no capability write method.
  - `agent: "read"` is refused for views in the configure and system groups and for any view whose entity holds credentials.
  - Links are owned, released and export `GET`.
  - Each `typed` or `ceremony` action has a behavioural test: its route answers 428 without a confirmation, 409 on a stale digest, and compares a typed name on the server.
- **`service-role-session-check.test.ts`:** the header states what marking a registry privileged means; `src/lib/gui/surface.ts` is listed in `MODULES` with `[]`, as are each `*-surface` platform plugin, the GUI install plugins and the host modules; the layout, the catch-all and the `/api/gui/*` routes are `session` entries. Loaders are judged by their own imports: loader, signal, search and `prepare` modules reach privileged code only through composers that settle the session. Route handlers get no memoisation from `getRequestUserAccess()`, so each host route checks once on its own path.
- **A credential canary test** (database-backed, `rls` project): it seeds canary values into every stored credential (data-source passwords, merchant secrets), runs every contributed loader, signal, search and `prepare` for the seeded organization, and asserts that no output contains a canary.
- **Budgets** in the `Build` job (§4.8) and a 360px Playwright smoke test of two pages (P1, a new dev dependency).

### 4.7 What the design adds to each invariant

1. **Organization isolation.** Loaders get their own RLS client and an organization resolved from the entity or the validated current organization; every entity in a path must resolve to the same organization; no cross-request cache holds a `ViewModel` (the assistant re-serves only the same user's stored event, after that step's own authorization); GUI code holds no client; the manifest carries one organization.
2. **Every write enters the runtime.** A GUI's only write path is `kit.act`, which reaches a route the surface test proves exists, is owned, released and gated, or exempt with a `PaymentExemption`. The host adds three GET routes and a `prepare` POST classified as a read. Deletes are contributed human-only actions with typed confirmation, served by gated `DELETE` routes that compare the typed name on the server, and each new method on a released path is recorded as a release decision.
3. **Vendor details behind providers.** Views call capabilities; vendor-cost views are gated like writes and polled sparingly; live values come from an optional metering-provider method; LLMs sit behind `llmProviders`.
4. **Credentials stay server-side.** Only declared keys leave the server, and the host strict-parses every boundary output: views and their embeds, `ext`, signals, status, search, ceremonies and prefill. Secret inputs are `writeOnly`: never prefilled, proposed or echoed. The canary test checks every contributed output. The manifest carries only a name and an email. An MCP bridge, if it ships, uses MGM-issued tokens (§9).
5. **Money is reproducible and a person closes a period.** The close route requires `{ confirmation: { digest, acknowledged } }` and answers `428 billing_confirmation_required` without it. One code-owned migration, shipped in one commit with its database tests and the regenerated `database.gen.ts`:
   - adds `fn_billing_period_close_digest(_period_id)`, a digest of the reviewed totals (per-tier and grand totals, bill count, energy and the unbilled households);
   - reissues `fn_close_billing_period(_period_id UUID, _digest TEXT, _acknowledged TEXT[])` and drops the earlier two-argument form in the same migration, so exactly one close function exists;
   - has the function lock the period row, recompute the digest and the required acknowledgement set (always `totals-copied`, plus `unbilled:<n>` when n households have no bill, which carries #167's warn-but-allow rule), and answer `409 billing_close_stale` on any difference;
   - adds a status trigger that allows the transition into `closed` only while a transaction-local setting that only this function sets is on. `service_role` is not exempt, and the database test covers it; the structural reason is that the function is the definition of closing and `service_role` may execute it, so an exemption would add no capability the function lacks;
   - applies `REVOKE EXECUTE … FROM PUBLIC, anon; GRANT … TO authenticated, service_role` to both functions.

   The route maps the request onto that call: the body `{ confirmed: true }` gives way to `{ confirmation }`, `409 billing_unresolved_households` becomes the `unbilled:<n>` acknowledgement and its warnings, and a missing or stale confirmation answers 428 or 409. The legacy close button switches to the host's ceremony step in the same commit. Human-only and ceremony actions never become agent tools: the types say so and `contribute()` and the tool builder check it at run time. Readings from the fixture provider are labelled synthetic everywhere and the provider registers only under an installation setting.
6. **Releases are explicit.** The dispatcher refuses any href that fails `isReleasedRoute` or that the contributing plugin does not own; the manifest drops unreleased nav entries, actions and attached views; `DELETE` gets method-aware release entries.
7. **Session checks.** The layout, the catch-all, every `/api/gui/*` route and every assistant step run `getRequestUserAccess()` or a composer that runs `authorizeOrganization` on their own path, and the import-graph test lists each new module explicitly.

### 4.8 Budgets for 3G

The current production build's root JavaScript is about 132 KB gzipped before any page code, plus 11 KB of CSS. Budgets above that floor, measured in `Build` from the build output and in the Playwright smoke on a throttled 3G profile (1.6 Mbps, 300 ms round trip), fail the job on regression:

| Budget | Gridtop | gui-lite |
|---|---|---|
| Shell JS above the framework, first load | ≤ 40 KB gz (`cmdk`, sheets and the assistant chunk load on first use; assistant ≤ 25 KB) | ≤ 12 KB gz |
| Stylesheet | ≤ 20 KB gz | ≤ 6 KB gz, system fonts, no icons |
| Fonts | one variable Inter, ≤ 50 KB, `preload: false`, `display: "optional"` | none |
| Manifest | ≤ 8 KB gz for an organization with ten microgrids | same |
| Today HTML plus RSC payload | ≤ 30 KB gz | ≤ 20 KB gz |
| A 50-row collection's RSC payload | ≤ 40 KB gz | same |
| `/payment/callback` | ≤ 15 KB HTML and inline CSS, no JavaScript | — |
| Usable on a low-end Android over throttled 3G | ≈ 5 s cold, ≈ 1.5 s per soft navigation | ≈ 4 s cold |

Signals are not in the manifest, so watchers never delay the first byte; each loader has its own client, so composers' `getUser()` calls run in parallel rather than queueing on one client; action specs and their schemas are fetched when a form opens.

## 5. Worked examples

### 5a. Billing

```ts
// src/lib/billing/surface.ts (server-only), registered by the billing-surface platform plugin.
import type { Contribution } from "@/lib/gui/surface";
import { CloseBody, CreatePeriodBody, GenerateBody, PeriodReviewExt, checklistExample, closeCeremony,
  closeChecklist, composeBilling, periodsCollection, periodsEndedButOpen, periodsExample, reviewCollection,
  reviewExample, sendExample, sendQueue } from "./deps";

export const BILLING_SURFACE: Contribution = {
  plugin: "billing",
  nav: [
    { id: "billing.nav", view: "billing.periods", title: "Billing", icon: "billing", group: "money", order: 10 },
    { id: "billing.tariff", href: "/microgrids/[id]/setup/rates", needs: ["microgrid"], legacy: true,
      title: "Tariff", icon: "tariff", group: "configure", order: 10 },        // legacy page until P3
  ],
  views: [
    { id: "billing.periods", kind: "collection", path: "/microgrids/[id]/billing",
      params: { id: "microgrid" }, entity: "id", attach: { to: "microgrid", order: 20, hint: "tab" },
      cost: "db", agent: "read", declares: { keys: ["period", "status", "bills", "energy", "total"] },
      examples: [periodsExample], load: periodsCollection },
    { id: "billing.period-review", kind: "collection", path: "/microgrids/[id]/billing/[periodId]",
      params: { id: "microgrid", periodId: "billingPeriod" }, entity: "periodId", cost: "db", agent: "read",
      live: { refreshSeconds: 30, staleAfterSeconds: 120 },                    // payment state stays fresh
      declares: { keys: ["household", "account", "energy", "total", "payment"], prefixes: ["tier."] },
      ext: { schema: "billing.period-review@1", zod: PeriodReviewExt }, examples: [reviewExample],
      async load(req) {
        const c = await composeBilling({ supabase: req.supabase, organizationId: req.organizationId, readOnly: true });
        if (!c.ok) return { ok: false, status: c.status, message: c.message };   // 500 and 503 pass through
        try {
          const s = await c.data.billing.getPeriodSummary(req.params.periodId);
          if (!s.ok) return { ok: false, status: 404, message: s.message };
          return { ok: true, model: await reviewCollection(req, s.data) };
        } finally { await c.data.dispose(); }
      } },
    { id: "billing.close-checklist", kind: "flow", path: "/microgrids/[id]/billing/[periodId]/close",
      params: { id: "microgrid", periodId: "billingPeriod" }, entity: "periodId",
      attach: { to: "billingPeriod", order: 10, hint: "section" }, cost: "db", agent: "read",
      declares: { keys: ["readings", "generate", "review", "ura", "close", "send", "collect"] },
      examples: [checklistExample], load: closeChecklist },
    { id: "billing.send-queue", kind: "collection", path: "/microgrids/[id]/billing/[periodId]/send",
      params: { id: "microgrid", periodId: "billingPeriod" }, entity: "periodId",
      attach: { to: "billingPeriod", order: 30, hint: "tab" }, cost: "db",   // phone numbers: no agent read
      declares: { keys: ["household", "total", "link"] }, examples: [sendExample], load: sendQueue },
  ],
  actions: [
    { id: "billing.create-period", title: "New billing period", target: "microgrid",
      input: CreatePeriodBody, inputVersion: 1, method: "POST", path: "/api/billing-periods",
      bind: { body: { microgridId: "microgrid" } }, risk: "money", humanOnly: false, confirm: "confirm" },
    { id: "billing.generate-bills", title: "Generate bills", target: "billingPeriod",
      input: GenerateBody, inputVersion: 1, method: "POST", path: "/api/billing/generate",
      bind: { body: { periodId: "billingPeriod" } }, risk: "money", humanOnly: false, confirm: "confirm" },
    { id: "billing.close-period", title: "Close period", target: "billingPeriod",
      input: CloseBody, inputVersion: 1, method: "POST", path: "/api/billing-periods/[periodId]/close",
      bind: { path: { periodId: "billingPeriod" } }, risk: "money", humanOnly: true, confirm: "ceremony",
      prepare: closeCeremony },
  ],
  signals: [{ id: "billing.period-due", load: periodsEndedButOpen }],   // "your move": links to the checklist
};
```

The body schemas live in `src/lib/billing/schemas.ts` (`CreatePeriodBody`, `GenerateBody`, `CloseBody`, `PeriodReviewExt`), and P2 makes the routes parse with them. `reviewCollection` emits columns such as `{ key: "total", label: "Total (UGX)", type: "money", priority: 1, copy: "bare" }`, so a GUI that only prints `text` already pastes `4,216,800` into URA's portal. `closeCeremony` returns the statement, the per-tier and grand totals (bare), one warning per household without a bill, the acknowledgement copy the close dialog uses today, verbatim ("I have copied the tier values into URA's portal and confirm these totals"), plus "I am closing with 1 household not billed" when it applies, and the digest the database recomputes. Once the period is closed its `links` carry "Export CSV for URA" and each row carries "Bill PDF", both GET downloads every GUI can offer. When Billing is off for an organization its apps, tabs, tiles, signals and tools leave that organization's GUI; a bookmarked billing page still opens read-only with the host's notice ("Billing is off for Kasana Energy Co-op; data is kept") and every action disabled with its reason, because reads pass the gate and writes do not.

### 5b. A partner's SMS-reminders plugin

A partner writes `sms-reminders`, compiled in and reviewed as #30 requires. Its code-owned changes: a catalog entry with `dependencies: ["billing"]`, its routes `/api/sms-reminders/send` and `/api/sms-reminders/settings` (gated with `gateRoute`) and its pages; released-routes entries for those paths and for `/microgrids/[id]/sms-reminders` and `/organizations/[id]/sms-reminders`; and the migration every new plugin name needs for the known-plugin check. Its own files: a provider registry for the SMS vendor, its schemas, and this whole GUI integration:

```ts
// src/lib/sms-reminders/schemas.ts: the route and the surface parse with the same schemas.
import { z } from "zod";
export const SendReminderBody = z.strictObject({ lineItemId: z.uuid() });   // no free text, no recipient
export const SmsSettingsBody = z.strictObject({
  organizationId: z.uuid(),
  vendor: z.enum(["africastalking", "twilio"]),
  senderId: z.string().max(11),
  apiKey: z.string().min(16).meta({ writeOnly: true }),                    // never prefilled, proposed or shown
});
```

```ts
// src/lib/sms-reminders/surface.ts (server-only): the plugin's whole GUI integration.
import type { Contribution } from "@/lib/gui/surface";
import { SendReminderBody, SmsSettingsBody } from "./schemas";
import { failedSendsToday, readSends, readSettings, sendStatement, sendsExample, settingsExample,
  unpaidWithPhone } from "./read";

export const SMS_SURFACE: Contribution = {
  plugin: "sms-reminders",
  views: [
    { id: "sms-reminders.sends", kind: "collection", path: "/microgrids/[id]/sms-reminders",
      params: { id: "microgrid" }, entity: "id", attach: { to: "microgrid", order: 60, hint: "tab" },
      cost: "db", declares: { keys: ["household", "bill", "sentAt", "status"] }, examples: [sendsExample],
      load: async (req) => ({ ok: true, model: await readSends(req) }) },
    { id: "sms-reminders.settings", kind: "record", path: "/organizations/[id]/sms-reminders",
      params: { id: "organization" }, entity: "id", attach: { to: "organization", order: 80, hint: "tab" },
      cost: "db", declares: { keys: ["vendor", "senderId", "apiKeySet"] }, examples: [settingsExample],
      load: async (req) => ({ ok: true, model: await readSettings(req) }) },   // the key shows only "set"
  ],
  actions: [
    { id: "sms-reminders.send", title: "Send reminder", target: "lineItem",
      input: SendReminderBody, inputVersion: 1, method: "POST", path: "/api/sms-reminders/send",
      bind: { body: { lineItemId: "lineItem" } },              // the row's own id, filled by the host
      risk: "external", confirm: "confirm", humanOnly: false,
      prepare: sendStatement,                                  // the confirm step names the recipient
      async available(req, ids) {
        const ok = await unpaidWithPhone(req, ids);
        return Object.fromEntries(ids.map((id) => [id, ok.has(id) ? true : "Paid, or no phone number"]));
      } },
    { id: "sms-reminders.save-settings", title: "Save SMS settings", target: "organization",
      input: SmsSettingsBody, inputVersion: 1, method: "PUT", path: "/api/sms-reminders/settings",
      bind: { body: { organizationId: "organization" } },
      risk: "write", confirm: "confirm", humanOnly: true },     // credentials: never a tool
  ],
  signals: [{ id: "sms-reminders.failed", load: failedSendsToday }],   // links to the sends tab
};
```

```ts
// src/lib/sms-reminders/surface-plugin.ts: registered on the root next to the plugin's routes and provider.
import type { Context, Plugin } from "cordis";
import "@/lib/gui/surface";
import { SMS_SURFACE } from "./surface";

export const SmsRemindersSurfacePlugin = {
  name: "sms-reminders-surface",
  inject: ["mgmSurface"],
  apply(ctx: Context) {
    ctx.effect(() => ctx.mgmSurface.contribute(SMS_SURFACE));   // unloading removes all of it
  },
} satisfies Plugin.Object<void>;
```

The host appends `sms-reminders.send` to every collection row whose entity or related entity is a `lineItem`, after billing's own actions, labelled "Send reminder · SMS reminders", and refuses a contributed title that duplicates one of billing's own actions. It fills `lineItemId` from the row, so the form has no fields, and the confirm step shows the statement from `sendStatement`: "SMS to Nakato House (…4471): October bill, UGX 150,200 due". With no change to any GUI, Gridtop shows the control in each bill row's ⋯ menu and in the inspector; `gui-lite` shows a button on each bill card; the assistant gains `propose:sms-reminders.send` with an input that carries no free text and no recipient, so "remind the three unpaid households" becomes three dashed proposals a person submits one by one. The failed-sends alarm links to the plugin's own tab, and Configure gains the SMS settings tab, whose key is `writeOnly`. Turning the plugin off for an organization removes its tabs, row controls, signal and tools from that organization's GUI; a bookmarked page of it still opens read-only with the host's notice, and its routes answer the gate's 409. That is the one rule for every plugin: a disabled plugin's contributions leave nav, attachments, rows, signals and tools; its db views stay readable with a notice; its vendor views and writes refuse.

### 5c. A minimal alternative GUI

The first version of `gui-lite`: about 60 lines of Shell, the kit's generic renderer and a small system-font stylesheet. It passes conformance: an organization select, the Needs-you count, every status item (so the assistant is reachable), the trail, nav by group with a context picker, and `kit.Link` for soft navigation.

```tsx
// src/gui/lite/shell.tsx: a whole Shell that passes conformance, for 2G phones.
"use client";
import type { NavEntry, NavGroup, ShellProps } from "@/lib/gui/contract";
import { Link, SignOut, navigate, useManifest, useSignals, usePathname, useView } from "@/lib/gui/kit";

const GROUPS: [NavGroup, string][] =
  [["operate", "Operate"], ["money", "Money"], ["configure", "Configure"], ["system", "System"]];

export function LiteShell({ children }: ShellProps) {
  const { org, orgs } = useManifest();
  const view = useView();
  const signals = useSignals();                      // streamed after first paint
  return (
    <div className="lite">
      <header>
        {orgs.length > 1 ? (
          <select aria-label="Organization" value={org.id}
            onChange={(e) => { const o = orgs.find((x) => x.id === e.target.value); if (o) navigate(o.href); }}>
            {orgs.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
          </select>
        ) : <strong>{org.name}</strong>}
        <Link href="/">Needs you ({signals ? signals.count : "…"})</Link>
        {org.status.map((s) => <Link key={s.id} href={s.open}>{s.label}: {s.state}</Link>)}
      </header>
      <nav aria-label="You are here">
        {view.trail.map((t) => <Link key={t.href} href={t.href}>{t.label}</Link>)}
      </nav>
      <main id="main">{children}</main>
      <nav aria-label="Apps">
        {GROUPS.map(([group, title]) => (
          <details key={group}>
            <summary>{title}</summary>
            <ul>
              {org.nav.filter((n) => n.group === group).sort((a, b) => a.order - b.order)
                .map((n) => <li key={n.id}><NavItem entry={n} /></li>)}
            </ul>
          </details>
        ))}
        <SignOut />
      </nav>
    </div>
  );
}

function NavItem({ entry }: { entry: NavEntry }) {
  const view = useView();
  const path = usePathname();
  const href = view.href(entry);
  if (href) return <Link href={href} current={path === href}>{entry.title}</Link>;
  const kind = entry.needs.find((k) => !view.scope[k]) ?? "microgrid";
  return (                                          // context picker: which microgrid?
    <details>
      <summary>{entry.title}…</summary>
      <ul>
        {view.choices(kind).map((place) => {
          const to = view.href(entry, { [kind]: place });
          return to ? <li key={place.id}><Link href={to}>{place.label}</Link></li> : null;
        })}
      </ul>
    </details>
  );
}
```

```ts
// src/gui/lite/meta.ts: read by src/gui/meta.ts at boot, without React.
import type { GuiMeta } from "@/lib/gui/contract";
export const LITE_META = {
  name: "lite", title: "MGM Lite", version: "0.1.0", contract: "^0.1",
  styles: "./styles.css",          // system fonts, no icons; built and linked only when MGM_GUI=lite
  dependencies: [],
} satisfies GuiMeta;
```

```ts
// src/gui/lite/index.ts: no "use client" and no CSS import; importable from server and client code.
import type { GuiPlugin } from "@/lib/gui/contract";
import { KitView } from "@/lib/gui/kit";
import { LiteShell } from "./shell";
import { LITE_META } from "./meta";

export const LiteGui: GuiPlugin = { ...LITE_META, Shell: LiteShell, View: KitView };
```

No sidebar, tables or dialogs: collections become cards by column priority, bare columns get copy blocks, and the host's steps render as plain pages through the kit's default frame. It shows every capability, every action at its confirmation level, the close ceremony, download links, the SMS action from 5b and the assistant thread. Its browser floor is a current Android WebView or KaiOS 3: writes need client JavaScript, so a no-JavaScript HTML-form GUI for Opera Mini is a non-goal for v0.

## 6. The default GUI: Gridtop

Gridtop (`src/gui/gridtop`, `MGM_GUI=gridtop`) takes its shell grammar from the desktop-environment concept and its discipline from the pragmatic one: no URL moves, at most two bands above content, a 1366×768 target, 44px touch targets, tables that scroll sideways (`overflow-x-auto`, never `overflow-hidden`) and never clip, and dialogs that become sheets on phones.

### 6.1 The shell at four sizes

**Compact, 360–599px.** Band 1 is the 48px title bar (context, search or ask, Needs you, assistant). Band 2 is a single 44px window bar: the view title as a menu listing the attached tabs, plus the view's actions; it collapses on scroll. A 56px bottom dock holds three pinned apps and More. Records and the inspector push full screens with their canonical URL, so Android Back works. Forms, confirmation steps and the assistant are bottom sheets that snap to peek, half and full, by drag or by their buttons, with the decision bar fixed above the safe area; typed confirmations open full screen so the keyboard never covers them. **Vertical space at 360×640** (Chrome's 56px toolbar leaves 584px): 584 − 48 − 44 − 56 = 436px, about four bill cards; 480px once the window bar collapses.

```
┌──────────────────────────────────────┐
│ ◉ Kasana Central ▾      ⌕    ▲3   ◇  │ ① context · search/ask · Needs you · assistant
├──────────────────────────────────────┤
│ Bills · October 2026 ▾          ⋯    │ ② window bar: title menu (tabs), actions
├──────────────────────────────────────┤
│ ┌ Nakato House ─────────── ● Unpaid ┐│ collection → cards (priority-1 columns)
│ │ 118 kWh               150,200 UGX ││
│ │ [Share]   [PDF]               [⋯] ││ 44px targets
│ └───────────────────────────────────┘│
│ ┌ Achieng M. ───────────── ● Paid ──┐│
│ │  64 kWh                74,600 UGX ││
│ └───────────────────────────────────┘│
├──────────────────────────────────────┤
│   ▣ Today    ⚡ Live    ¤ Billing  ⋯  │ bottom dock: three pinned apps + More
└──────────────────────────────────────┘
```

**Medium, 600–1279px.** A 72px rail dock, labels under icons, groups separated by rules. Band 1 is the title bar; band 2 is the window bar in two 36px rows (title and actions, then tabs). One window; the inspector is a right sheet, docked at 360px from 1024px.

```
┌────┬──────────────────────────────────────────────────────────────────┐
│ ◆  │ Kasana Energy Co-op › Kasana › Kasana Central ▾  ⌕ ⌘K  ▲3   ◇    │
│────┼──────────────────────────────────────────────────────────────────┤
│ ▣  │ Billing · October 2026 · draft · EAT          [Generate]  [⋯]    │
│ ⚡ │ Bills · Close · Send · History · Payments                         │
│ ⌂  ├──────────────────────────────────────────────────────────────────┤
│ ── │ Household       Acct   kWh   Total (UGX)   Payment               │
│ ¤  │ Nakato House    0012   118      150,200   ● Unpaid       ⋯       │
│ ── │ Achieng M.      0007    64       74,600   ● Paid         ⋯       │
│ ⚙  │ (priority-3 tier columns hidden; the table scrolls sideways)     │
│ ── │ Totals                3,514    4,216,800                         │
│ ⛭  │                                         inspector sheet → 360px  │
└────┴──────────────────────────────────────────────────────────────────┘
```

**Expanded, 1280px and wider.** A 64px dock, the title bar, the window bar, a 24px status bar and three fixed tiles: main window, inspector (the selected record's attached views) and assistant (a status item's docked feed). Tiles never float or overlap. From 1280 to 1599px the inspector and assistant share one 360px column as tabs, leaving the main window 942px at 1366px; ⌘. collapses the column for the full CopyTable. From 1600px both are pinned (360px and 400px). **Vertical space at 1366×768 in a browser tab** (about 625px of viewport): 625 − 44 − 72 − 24 = 485px, about 13 CopyTable rows of 32px under the header and totals; below 800px of viewport height the status bar hides (14 rows); installed with Window Controls Overlay the title bar moves into the OS title area (about 18 rows).

```
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ ◆ Kasana Energy Co-op › Kasana › Kasana Central ▾   ⌕ Search or ask  ⌘K     ▲ 3   ◇ idle  AN │
├────┬────────────────────────────────────────────────────────┬────────────────────────────────┤
│ ▣  │ Billing · October 2026 · draft · Africa/Kampala   [⧉]  │ [Inspector]  Assistant         │
│ ⚡ │ Bills · Close · Send · History · Payments  [Generate ⋯] │ Nakato House · Acct 0012       │
│ ⌂  ├────────────────────────────────────────────────────────┤ meter M-4 since 1 Mar          │
│ ▤  │ Household  Acct  kWh   T1 (UGX)  T2 (UGX)  Total (UGX) │ Bill 150,200 · ● Unpaid        │
│ ── │ Nakato     0012  118     50,000    95,200     150,200  │ [PDF] [Share] [Send reminder]  │
│ ¤  │ Achieng M. 0007   64     50,000    19,600      74,600  │ ── attached ──                 │
│ ₵  │ …   CopyTable · 32px rows · bare cells                 │ Meter history                  │
│ ── │ Totals             3,514                    4,216,800  │ Payments · 2 attempts          │
│ ⚙  │                                                        │                                │
├────┴────────────────────────────────────────────────────────┴────────────────────────────────┤
│ Kasana Energy Co-op · Africa/Kampala 14:32 · live 12 s · 5 plugins on · Gridtop 0.1 · MGM 0.2 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
```

**Installed PWA.** `app/manifest.ts` declares `display_override: ["window-controls-overlay", "standalone"]`. **⧉ Pop out** opens the canonical URL (with its `ui.*` state) through `window.open` with `noopener`, and the OS manages the window. There is no in-app window manager; a `BroadcastChannel` only nudges sibling windows to refresh tray counts and is cleared on sign-out. `navigator.setAppBadge` shows the "your move" count. Manifest shortcuts are static, so "Billing" and "Record a reading" point at a host redirect, `/go/<nav id>`, which fills the current microgrid or opens the picker. The manifest, icons and `/go/*` join the released and public paths.

### 6.2 Navigation

- **Dock.** Apps from `nav`, grouped **Operate** (Today, Sites, Live, Households, Readings, Devices), **Money** (Billing, Payments), **Configure** (Tariff, Data source, Invoice branding, Payment accounts) and **System** (Plugins, Users, Profile, Organizations). `ViewState.href` fills each entry's segments by kind from the current scope; when a needed kind is missing the entry opens a picker from `choices`. A disabled plugin's apps are absent because the server filtered them.
- **Context path.** The title bar renders the trail (`Kasana Energy Co-op › Kasana › Kasana Central ▾`); each segment opens a sibling switcher with counts. It replaces the per-page breadcrumbs, the microgrid header, the Setup heading and the sub-tab bars, page by page as each migrates.
- **Window bar.** Title, subtitle (the billing timezone is always here), attached `tab` views and legacy tabs as links marked `aria-current`, the view's actions and links.
- **⌘K** (⌕ on phones; `cmdk`, MIT, a new dependency loaded on first open). `/` lists the current view's actions and the nav; `@` finds households (name, account number, phone suffix), microgrids and edges; `#` finds periods and invoices. Plain text shows deterministic matches first, then **Ask "…"** only when a status item offers `ask`; ⌘⏎ forces Ask; without the assistant it only searches. Commands never execute: a command for an action calls `kit.act`, so the action's own confirmation step runs; a command for another entity navigates to its intent URL.
- **Tray.** ▲ is the Needs-you count, linking to Today. ◇ is the assistant: off (enabled but not ready, e.g. no provider or budget spent), idle, working, needs you; absent, not greyed, when the plugin is disabled. Changes are announced in a polite live region.
- **Keys.** ⌘K, `g t` / `g l` / `g m`, `.` inspector, ⌘J assistant, ⌘. collapse or stop, `[` and `]` siblings. Single-key shortcuts never fire inside inputs, and Profile has a switch to turn them off or remap them (WCAG 2.1.4).

### 6.3 Every current page and its new home

No URL changes; new paths are only added. Phases refer to section 10.

| Current route | Gridtop home | View (owner, kind) | Phase |
|---|---|---|---|
| `/` | Operate › **Today**: Needs you, plus tiles attached by billing (money) and metering (live), which disappear when those plugins are off | `org.today` (organization-directory, feed, bespoke) | P2 (P1: today's org cards inside the shell) |
| `/organizations`, `/organizations/[id]` | System › Organizations (installation scope, super admin); org record with Communities and Payment accounts tabs | `org.organizations`, `org.organization` | P3 |
| `/organizations/[id]/payments` | That org's Payment accounts tab | `payments.org-accounts` | P3 |
| `/communities`, `/communities/[id]` | Operate › Sites grouped by community; community record | `community.communities`, `community.community` | P3 |
| `/communities/[id]/payment`, `/invoice` | Community › Payment and Invoice tabs; Configure › Invoice branding | `payments.community-account`, `billing.invoice-branding` | P3 |
| `/microgrids` | Operate › Sites: tiles with online state, open period, owed | `community.microgrids` | P3 |
| `/microgrids/[id]` | P0: the middleware redirect points at Operations instead of Households; P3: Operate › **Live** | `metering.live` (metrics, bespoke) | P0, P3 |
| `…/operations` (on `feat/operations-savings`) | Live › Day tab, with billing's savings (grid tariffs, savings settings) attached | `metering.site-day` (metrics), `billing.savings` (metrics, attached) | P1 pilot, once those PRs merge |
| `…/billing` | Money › Billing, a microgrid tab | `billing.periods` | P2 |
| `…/billing/[periodId]` | Period review with the close rail | `billing.period-review` (bespoke) | P2, after the parity table |
| `…/billing/[periodId]/history` | Period › History tab | `billing.period-history` (feed) | P2 |
| new `…/billing/[periodId]/close` | Close checklist: a rail on desktop, a page on phones | `billing.close-checklist` (flow) | P2 |
| new `…/billing/[periodId]/send` | Period › Send tab: the send queue | `billing.send-queue` (collection, bespoke) | P2 |
| `…/setup` | Redirect to Devices, kept for bookmarks | — | P1 |
| `…/setup/households`, `…/[householdId]` | Operate › Households: list plus inspector, wizard form; household record with meter history attached | `community.households`, `community.household`, `metering.assignment-history` | P3 |
| new `…/readings` | Operate › Readings: record a reading or an opening reading, on a phone | `metering.readings`; actions on `/api/metering/readings` and `/api/meter-readings/opening` | P3 |
| `…/setup/edges`, `…/[edgeId]`, `…/shared` | Operate › Devices: edge groups, Discover sheet, Shared tab; edge in the inspector | `metering.devices` (bespoke), `metering.edge`, `metering.shared-devices` | P3 |
| `…/setup/openems-backend` | Configure › Data source (bespoke form, write-only secrets) | `metering.data-source` | P3 |
| `…/setup/rates` | Configure › Tariff: the tier editor and the grid-tariff editor (bespoke forms) | `billing.tariff`, `billing.grid-tariffs` | P3 (legacy nav entry until then) |
| `…/tenants` | Deleted | — | P0 |
| `/settings/profile`, `/users`, `/plugins` | System › Profile (also the avatar menu), Users, Plugins (with the pause preview and Interface) | `org.profile`, `org.users`, `org.plugins` | P3 |
| `/settings/api-tokens` | Absent from the dock until released | — | — |
| `/login`, `/forgot-password`, `/reset-password`, `/accept-invite`, `/no-access`, `/setup` | Host-owned, token-styled; `PublicFrame` (server component) from P1 | — | P0, P1 |
| `/review` | Redirect kept; `review-bills.tsx` deleted after `csvCell` moves to `lib/billing` | — | P0 |
| `/payment/callback` | Host `route.ts` returning a static, branded HTML receipt: community name, logo and WhatsApp contact read on the server, copy following `Accept-Language`, ≤ 15 KB, no JavaScript; its public session-check entry moves with it | — | P0 |
| `/p/[slug]`, `/pay` | Unchanged route handlers; plain HTML states for "payments are off" and "too many attempts" | — | P0 check |
| `/_dev/components` | Kept until #54 has a replacement; CLAUDE.md updated when it goes | — | later |
| new `/payments?org=` | Money › Payments: the organization's reconcile queue | `payments.queue` | P3 |
| new `/assistant`, `/assistant/[threadId]`, `/assistant/proposals/[id]` | Assistant tile or sheet; pages in GUIs without a dock | `assistant.threads`, `assistant.thread` (feed), `assistant.proposal` (record) | P4 |
| new `/go/[nav]` | Host redirect for PWA shortcuts | — | P1 |

## 7. Key screens

### 7.1 Today: Needs you

```
┌ Today · Kasana Energy Co-op · Thu 6 Nov · 14:32 EAT ──────────────────────────────────────┐
│ NEEDS YOU (5)                                       │ KASANA CENTRAL            live 12 s   │
│ ▲ ALARM  Edge pi2 offline since 11:40 · 4 homes     │ ☀ 3.2 kW   ▮ 78% ↑   ⚡ 0.0   ⌂ ≈2.9  │
│          [Open edge]                   [Snooze ▾]   │ edges 1/2 online · 49 households      │
│ ● YOUR MOVE  October ended 6 days ago, not closed   │───────────────────────────────────────│
│          48 bills · UGX 4,216,800 · 1 not billed    │ MONEY · OCTOBER (UGX)                 │
│          [Open close checklist ›]                   │ billed  4,216,800  ██████████         │
│ ● YOUR MOVE  1 payment needs reconciliation         │ paid    3,580,000  ████████▌          │
│          [Review ›]                                 │ owed      636,800  ▒▒  7 households   │
│ ┆◇ PROPOSED · Assistant · New billing period    ┆   │───────────────────────────────────────│
│ ┆  1–30 Nov 2026 · no overlap · tariff v3       ┆   │ ACTIVITY                              │
│ ┆  [Review ›]                         [Dismiss] ┆   │ 09:12 Payment received · 74,500       │
│ i INFO   5 payments arrived overnight               │ yesterday · October bills generated   │
└─────────────────────────────────────────────────────┴───────────────────────────────────────┘
```

- **Ranking:** critical alarms, "your move" by due date, proposals (money first), questions, info. Items clear when their condition clears; Snooze is the only manual hide. "Your move" never shows Approve; it links to its step.
- **No LLM on load.** Signals are deterministic watchers over stored data: periods past their end, missing readings, reconciliation and dead letters, bills unpaid for 14 days, edge status (cached 60 s after authorization). They stream after first paint from `/api/gui/signals`, each with a 1.5 s budget; a miss shows "not checked" and never holds the page.
- **Plugins own their tiles.** Money is a tile attached by billing and Live by metering; with either plugin off, its tile is gone.
- **Adapts to fleet size:** one microgrid fills the right column; several become tiles, problems first. Phones show the queue, then the tiles.

### 7.2 Microgrid Live: energy beside money

```
┌ Live · Kasana Central · EAT ───────────────────── sampled 12 s ago · every 15 s · [Table] ┐
│ ENERGY NOW                                     │ MONEY · OCTOBER (draft)              UGX │
│  ☀ PV 3.2 kW ════▶ ( bus ) ════▶ ⌂ Load ≈2.9 kW │ metered      3,514 kWh                   │
│                      ║                         │ billed       4,216,800  ██████████        │
│  ⚡ Grid 0.0 kW ──────╢                         │ paid         3,580,000  ████████▌         │
│                      ▼                         │ owed           636,800  ▒▒  7 households  │
│  ▮ Battery 78% · charging 0.3 kW               │ solar this month  1,284 kWh               │
│  ≈ derived: load = PV − battery − grid         │                                          │
├────────────────────────────────────────────────┴──────────────────────────────────────────┤
│ EDGES         status     last seen   SoC        │ LAST 24 H  PV ._/▔▔\_.  SoC ‾‾\_/‾  Load │
│ pi1 main      ● online   12 s        78%        │ [Day] [7 d] [30 d calendar]              │
│ pi2 block B   ▲ OFFLINE  2 h 52 m    ░ 9% 11:38 ░│                                          │
└─────────────────────────────────────────────────┴──────────────────────────────────────────┘
```

- **A bespoke renderer over a `metrics` view** (`metering.live`, `cost: "vendor"`, `live: { refreshSeconds: 15, staleAfterSeconds: 45 }`). Every value carries `at`; past the threshold it turns grey and hatched with its age, measured against the server's `now`. Derived values carry ≈ and a dashed edge. **[Table]** shows the table twin, which screen readers also get.
- **Quiet board:** neutral at rest; flow dashes animate only while fresh, never under reduced motion or a constrained connection.
- **Data path:** an optional `MeteringProvider.getLiveSnapshot()` beside the working tree's `getSiteSamples`, implemented by the OpenEMS provider and the fixture provider. The fixture provider registers only when the installation setting `MGM_METERING_FIXTURES` is on (#43–#45 and #61's synthetic installs); every Value it produces carries `synthetic` and a "Synthetic data" band shows across the shell. The vendor view is gated like a write, so a disabled Metering plugin shows "Metering is paused for Kasana Energy Co-op; data is kept" and no tiles. Snapshots are cached 10 s after authorization.
- **Polling** runs only while the page is visible and slows to 60 s or manual when `navigator.connection.effectiveType` is `2g` or `3g`, `saveData` is set, or the `Save-Data` or `ECT` hint says so (the kit's `useLive` owns this policy for every GUI).

### 7.3 Billing period review and the seven-step close

```
┌ Billing · October 2026 · draft · Africa/Kampala · tariff v3 pinned ──── [⧉] [Generate ⋯] ┐
│ CLOSE CHECKLIST           │ Bills · Send · History · Payments                            │
│ ✓ 1 Readings  48/49       │ Household     Acct  kWh  T1 (UGX)  T2 (UGX)   Total (UGX)    │
│ ✓ 2 Generate  5 Nov 10:02 │ Nakato House  0012  118    50,000    95,200       150,200    │
│ ● 3 Review    2 to check  │ Achieng M.    0007   64    50,000    19,600        74,600    │
│ ○ 4 URA copy [Copy cols]  │ Ssempala K.   0031   41    41,000         0        46,000    │
│ ○ 5 Close     you   [›]   │ …                                                            │
│ ○ 6 Send      on a phone  │──────────────────────────────────────────────────────────────│
│ ○ 7 Collect   41/48 paid  │ Totals              3,514                       4,216,800    │
│                           │ ⌘C on a column copies it bare, column by column              │
└───────────────────────────┴──────────────────────────────────────────────────────────────┘

┌ Close October 2026 · Kasana Central ──────────────────────────────────┐
│ Bills become final. Later corrections are recorded in History.        │
│ Bills                48        Energy                 3,514 kWh       │
│ Total (UGX)   4,216,800        Households not billed  1               │
│ ▲ Okello J.: no reading for October, not billed                       │
│ ☐ I have copied the tier values into URA's portal and confirm these   │
│   totals                                                              │
│ ☐ I am closing with 1 household not billed                            │
│                                        [Cancel]   [Close period]  off │
└───────────────────────────────────────────────────────────────────────┘
```

- **The rail is `billing.close-checklist`** (a `flow`). Step 1 counts readings and links to Readings for the missing one; 2 is Generate; 3 lists exceptions first; 4 is the URA copy, a laptop job (phones get the CSV link and a note); 5 opens the ceremony; 6 is the send queue, done on a phone; 7 links to collection and reconciliation.
- **The ceremony** is the host's `ConfirmStep`, framed by Gridtop's `Overlay`, which carries the look of today's `ClosePeriodDialog` (max height, a sheet on phones). `prepare` returns facts, warnings, acknowledgements and the digest; Close stays off until every box is ticked by a real tap or click. A `409 billing_close_stale` (someone regenerated meanwhile) re-prepares with the new totals and says why. Once closed, the step shows "Export CSV for URA" from the view's links. The assistant can link to this step, never submit it.
- **The table** is CopyTable: bare cells, currency in headers, 32px rows, the totals footer a `<div>` below, virtualised above 100 rows (`@tanstack/react-virtual`, MIT, new). Phones get cards and a stepper.

**Send queue** (`billing.send-queue`, the Send tab): one row per household with the bill total and two links. **Share** (Web Share) sends the bill PDF and a message with the pay link; the queue fetches each PDF ahead in the background when `navigator.canShare({ files })` holds, because the share call must follow the tap within the browser's activation window. **WhatsApp** opens `https://wa.me/<phone>?text=…` with the same message and pay link, straight into that household's chat. Per household that is one Share and then a contact pick in WhatsApp, or one tap into the right chat and then Send. Pay links come from the Payments plugin's targeted action on each line item ("Create payment link", `POST /api/billing-line-items/[lineItemId]/url`); with Payments off the message carries the amount and the PDF only. Progress is counted per phone ("12 shared on this phone"), because nothing records a send on the server; a stored send record would need its own table and gated route (decision 9). Desktops get Copy link and Download. It is tested on a real Android with WhatsApp before P2 closes, since some share targets drop `text` when a file is attached.

**Parity before `BillingTable` goes.** The legacy period page stays mounted inside the shell until every row below is contributed and tested; only then is its `page.tsx` deleted:

| Legacy control (`BillingTable`, `src/components/billing/*`) | Becomes | Route | Human-only | Confirm | Form |
|---|---|---|---|---|---|
| New billing period | `billing.create-period` | `POST /api/billing-periods` | no | confirm | `ActionForm` (dates) |
| Preflight: readings and the energy fetch | checklist step 1, `metering.readings`, a metering action on the existing `/api/openems/energy` route | as classified today | no | none | — |
| Generate bills | `billing.generate-bills` | `POST /api/billing/generate` | no | confirm | none (bound) |
| Regenerate one or several rows, open period | `billing.regenerate` | the generate route with line items; preview from `POST /api/billing/regenerate-preview` in `prepare` | no | confirm | row picker form |
| Regenerate, closed period | `billing.regenerate-closed` | same | yes | typed | reason |
| Manual usage with a reason | `billing.set-usage` | `PATCH /api/billing-line-items/[lineItemId]/usage` | yes | confirm | inline form (Gridtop), `ActionForm` elsewhere |
| Mark paid, payment notes | `billing.set-payment-status` | `PATCH …/payment-status` | yes | confirm | notes |
| Payment link popover | `payments.create-link` (targeted) then a share link | `POST …/url` | no | none | — |
| Bill PDF, Export CSV for URA | links `billing.bill-pdf`, `billing.export-csv` | `GET …/pdf`, `GET …/export-csv` | — | — | — |
| Close period | `billing.close-period` | `POST …/close` | yes | ceremony | none |
| Delete controls on this screen | the general delete rule (§4.7 item 2) | gated `DELETE` | yes | typed | — |
| Row actions menu, sticky selection bar | row `actions` and `links`; bulk selection is out of scope for v0 | — | — | — | — |
| 30-second payment polling | `live.refreshSeconds: 30` through `kit.useLive`; Realtime on desktop in P5 | — | — | — | — |

### 7.4 Devices: edges and discovery

```
┌ Devices · Kasana Central ─────────────────────────────────────────── [Discover]  [⋯] ┐
│ Data source  OpenEMS · connected · tested 2 h ago                  [Test]  [Edit ›]  │
│ ▾ pi1 main · ● online · 6 devices                                                    │
│     meter0   consumption meter   → Nakato House          0.9 kW                      │
│     meter1   consumption meter   → unassigned   ▲        0.4 kW     [Assign…]        │
│     ess0     battery                                      78%                        │
│ ▸ pi2 block B · ▲ offline 2 h 52 m · 4 devices                                       │
│ ┆ FOUND, NOT REGISTERED (2) · suggested from device signatures                    ┆  │
│ ┆ meter7   suggested: consumption meter    ◉ keep  ○ change ▾                    ┆  │
│ ┆ ess1     suggested: battery              ◉ keep  ○ change ▾                    ┆  │
│ ┆                                                  [Register 2 devices]          ┆  │
└──────────────────────────────────────────────────────────────────────────────────────┘
```

- **Suggestions** come from `classifyDeviceType` (`src/lib/openems/classify.ts`) through the metering capability's discovery result (`suggestedDeviceType`), so they appear with the assistant off; as `ActionRef`s with `provenance.by: "watcher"` they are dashed. **Register** is the person's action through the existing devices route.
- **Live values** load only when an edge is expanded (vendor cost). Rows scroll sideways and become cards on phones.
- **Credentials** are edited only in Data source, whose secret fields are `writeOnly`.

### 7.5 Payments: reconciliation

```
┌ Payments · Kasana Energy Co-op · all microgrids ───────────────────────────────────┐
│ Needs reconciliation 1 │ Pending 2 │ Unpaid > 14 days 3 │ Dead letters 0 │ Paid 41  │
├────────────────────────────────────────────────────────────────────────────────────┤
│ Achieng M. · October bill · Kasana Central                                         │
│ Bill (UGX)     74,600      Received (UGX)    74,500      Gap  100 short            │
│ Pesapal · mobile money · ref …9F2 · COMPLETED 3 Nov 14:02 EAT · checked 10 min ago │
│ ┌ attempt timeline ──────────────────────────────────────────────────────────────┐ │
│ │ 3 Nov 13:58 checkout created · 14:02 IPN completed 74,500 · 14:07 rechecked    │ │
│ └────────────────────────────────────────────────────────────────────────────────┘ │
│ [Refresh status]   [Accept exact]  off: amounts differ   [Record adjustment…]     │
│ ◇ Explain: "Two September attempts had the same 100 gap; both were adjusted."      │
└────────────────────────────────────────────────────────────────────────────────────┘
```

- The tabs are the collection's `filters`. **Refresh status** is `confirm: "none"` and proposable, on an exempt route whose `PaymentExemption` reaches `refreshPaymentStatus`. **Accept exact** and **Record adjustment** are human-only; the adjustment asks for a reason and a confirm step. The disabled button shows its server-resolved reason.
- **Explain** appears only with the assistant on; it reads the payment history view (marked `agent: "read"`) and has no tool for these actions.
- Until `/payments?org=` lands in P3, the same body renders as the period's Payments tab.

### 7.6 System › Plugins, with the pause preview and the GUI choice

```
┌ System · Plugins · Kasana Energy Co-op ───────────────────────────────────────────────┐
│ PLUGIN                  STATE    CONTRIBUTES                                  NEEDS    │
│ Organization directory  core     Today, Users, Profile, @ search             —        │
│ Community management    ● on     Sites, Households, 6 actions, 1 signal      —        │
│ Metering                ● on     Live, Readings, Devices, Data source, 3 sig. Community│
│ Billing                 ● on     Billing, Tariff, 9 actions, 2 signals       Metering │
│ Payments · Pesapal      ● on     Payments, 4 actions, 2 signals              Billing  │
│ SMS reminders           ○ off    Send reminder on bills, 1 signal            Billing  │
│ Assistant               ○ off    Assistant tile, Ask in ⌘K, 9 read · 7 propose tools  │
├───────────────────────────────────────────────────────────────────────────────────────┤
│ Turn Metering off for Kasana Energy Co-op?  This pauses Live, Readings, Devices,      │
│ Data source, 3 signals and these assistant tools: view:metering.site-day,             │
│ view:metering.readings, propose:metering.register-devices. Billing needs Metering     │
│ and will report not ready. Data is kept.          [Cancel]   [Turn off Metering]      │
├───────────────────────────────────────────────────────────────────────────────────────┤
│ INTERFACE   Gridtop 0.1.0 · active · contract ^0.1 ✓                                  │
│             MGM Lite 0.1.0 · installed · contract ^0.1 ✓                              │
│             Chosen by the installation setting MGM_GUI. A per-person choice comes later.│
└───────────────────────────────────────────────────────────────────────────────────────┘
```

- **"Contributes" and the pause preview** are computed from `mgmSurface`: the plugin's contributions plus those of plugins that depend on it, including the exact assistant tool names. The toggle is a human-only action at the confirm level on the existing plugins route.
- **Interface** lists `src/gui/meta.ts` with each GUI's range. It is read-only: the GUI is an installation choice, never an organization feature, so it stays out of `mgm_plugins`. Conformance results live in CI, not on this screen.

### 7.7 The assistant tile, with a proposal

```
┌ ◇ Assistant · Kasana Central · October ─────────── ● working   [■ Stop ⌘.] ┐
│ Sees: Billing › October 2026 · households shown as H-01…H-49              │
│ You: anything I should prepare for November?                              │
│  · read billing.periods                                0.3 s ✓            │
│  · read billing.period-review (October)                0.4 s ✓            │
│ ┌ from MGM ─────────────────────────────────────────────────────────────┐ │
│ │ October · owed UGX 636,800 · 7 households · no November period yet   │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ November has no period yet. Seven October bills are still unpaid.         │
│ ┌╌ ◇ Proposed by Assistant · New billing period ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ │
│ ┆ 1–30 Nov 2026 · Africa/Kampala · no overlap · tariff v3 pins later   ┆ │
│ ┆ [Review ›]                                                [Dismiss] ┆ │
│ └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ │
│ [ Ask about this period…                                          ] [↵]   │
│ OpenAI-compatible · model set by your operator · every step is logged     │
└───────────────────────────────────────────────────────────────────────────┘
```

- **Where:** the right column at 1280px and up (pinned from 1600px), a slide-over on medium screens, a bottom sheet snapping to 45% and 92% on phones, with Stop beside Send.
- **The proposal card is dashed** because its `ActionRef` has provenance. **Review** calls `kit.act(ref)`: the pre-filled form with fields marked "suggested", then the action's own confirm step. The card then turns solid and links to the record.
- **Grounding:** figures in "from MGM" cards are tool-result `Value`s; the prose is `Rich` text the server built, so a numeral matching no `Value` arrives already marked "unverified", and names arrive already de-pseudonymised.

## 8. Visual language

### 8.1 Tokens

The OKLCH tokens, the `--brand-hue` contract and the fixed semantic hues (success 152, warning 78, destructive 27) stay, and move into Gridtop's stylesheet. Spacing, radius, elevation and motion move from the unloaded `tailwind.config.ts` into `@theme`, and the JS config is deleted. The brand-hue safe range narrows to **200–340**, so no brand collides with solar yellow. Dark mode ("night ops") is first-class: a cookie holds system, light or dark, rendered on the server without a flash; `prefers-contrast: more` strengthens borders and focus outdoors.

| Token | Light | Dark | Use |
|---|---|---|---|
| `--background` | `oklch(98.5% 0.004 95)` | `oklch(15.5% 0.012 250)` | the desktop behind tiles |
| `--surface-1` | `oklch(100% 0 0)` | `oklch(19.5% 0.013 250)` | windows, cards |
| `--surface-2` | `oklch(97% 0.005 95)` | `oklch(23.5% 0.014 250)` | inspector, raised rows |
| `--surface-3` | `oklch(100% 0 0)` + `elev-3` | `oklch(27.5% 0.015 250)` | sheets, popovers (tone, not shadow, in dark) |
| `--border` | `oklch(90% 0.006 90)` | `oklch(32% 0.012 250)` | rules, inputs |
| `--foreground` | `oklch(18% 0.010 250)` | `oklch(94% 0.006 95)` | body text |
| `--muted-foreground` | `oklch(46% 0.012 250)` | `oklch(72% 0.012 250)` | secondary text, the proposal dash |
| `--subtle-foreground` | `oklch(56% 0.010 250)` | `oklch(62% 0.012 250)` | captions, 12px minimum |
| `--primary` / `-foreground` | `oklch(52% 0.16 h)` / `oklch(99% 0.004 95)` | `oklch(70% 0.14 h)` / `oklch(16% 0.02 h)` | brand controls; `h` = `--brand-hue`, default 262 |
| `--accent` / `-foreground` | `oklch(94% 0.035 h)` / `oklch(30% 0.12 h)` | `oklch(30% 0.06 h)` / `oklch(88% 0.06 h)` | selection, current item, the `accent` tone |
| `--ring` | `oklch(62% 0.15 h)` | `oklch(72% 0.14 h)` | two-channel focus ring |
| `--success` / `-muted` / `-fg` | `(52% 0.14 152)` / `(94% 0.05 152)` / `(30% 0.09 152)` | `(70% 0.14 152)` / `(30% 0.06 152)` / `(86% 0.10 152)` | paid, online, done |
| `--warning` / `-muted` / `-fg` | `(62% 0.15 78)` / `(95% 0.05 78)` / `(36% 0.09 78)` | `(78% 0.14 78)` / `(32% 0.06 78)` / `(88% 0.10 78)` | owed, attention |
| `--destructive` / `-muted` / `-fg` | `(55% 0.19 27)` / `(94% 0.04 27)` / `(36% 0.13 27)` | `(68% 0.18 27)` / `(30% 0.07 27)` / `(86% 0.08 27)` | offline, failed, delete |
| info | `--muted-foreground` plus ⓘ | same | no hue: quiet board |
| `--energy-solar` (+ `-ink`) | fill `(80% 0.15 98)`, ink `(50% 0.11 98)` | `(85% 0.14 98)` | PV, always with ☀ |
| `--energy-battery` | `(58% 0.11 195)` | `(74% 0.11 195)` | battery, with ▮ and SoC % |
| `--energy-grid` | `(50% 0.03 250)` | `(72% 0.03 250)` | grid, neutral slate, with ⚡ |
| `--energy-load` | `(55% 0.16 320)` | `(72% 0.14 320)` | load, with ⌂ |
| `--stale` | `(62% 0.01 250)` | `(55% 0.01 250)` | the hatch on stale values |

Contrast, computed via OKLCH to sRGB: text pairs reach at least 4.5:1 (foreground 18.8:1 light and 15.3:1 dark on `--surface-1`; each `-fg` on its `-muted` at least 8.8:1; subtle text 4.65:1 and 5.0:1); data marks reach at least 3:1 (lowest: light battery 4.0:1, stale hatch 3.6:1). Light `--success` darkens from 58% to 52% so it passes 4.5:1 as text. Solar's light fill (1.9:1) is only ever a fill behind ☀; lines and labels use `--energy-solar-ink` (6.0:1). A few chromas sit just outside sRGB and are gamut-mapped by the browser, so `design-tokens.test.ts` pins the computed ratios. Energy colours mark data only, never status or controls.

**Tone remap** (the status maps move from `status-chip.tsx` to `fmt.ts`): `neutral` stays, `warn` becomes `warning`, `alert` becomes `danger`, `success` stays, `brand` becomes `accent` (a label such as "OpenEMS", never a state). The edge `stale` state stops being a tone: its Value keeps the underlying tone and carries `at`, and every GUI hatches it once it is older than the view's `staleAfterSeconds`.

### 8.2 Type

- **One variable Inter** replaces Inter Tight and JetBrains Mono. Inter Tight is a display cut that closes up at 12–13px, exactly where dense tables live. Inter is hinted for small sizes on budget laptops' low-DPI screens, has tabular figures (`tnum`) for money columns and a slashed zero (`zero`) that separates 0 from O in account numbers and meter ids. One file, weights 400–600, Latin subset, `preload: false` and `display: "optional"`, ≤ 50 KB; missing glyphs fall back per glyph to the system font.
- **Monospace** uses the system stack (`ui-monospace, SFMono-Regular, Menlo, Consolas`), so it downloads nothing.
- **Constrained connections:** when `Save-Data` or the `ECT` hint says 2g or 3g, the root layout leaves the font class off and the system stack renders; since nothing is preloaded, the font is never fetched.
- **Scale:** 12 caption · 13 dense table (fine pointer only) · 15 body (fine pointer) · 16 body (coarse pointer) and every input, so iOS does not zoom · 18 title · 22 headline · 32 KPI and live readouts. Line height 1.45 body, 1.25 headings; nothing under 12px; a guard bans `text-[Npx]` (309 uses today).

### 8.3 Density, motion, icons, data-viz and accessibility

- **Density follows the pointer:** coarse gives 44px targets and 16px body; fine gives 32px rows and 13px tables. A per-person override lives in `localStorage`.
- **Motion:** 120 ms for state, 200 ms for sheets and tiles, one easing (`cubic-bezier(.2,.8,.2,1)`); none under reduced motion or a constrained connection; numbers never tween.
- **Icons:** `lucide-react` (ISC, new), imported per icon and mapped from the contract's `Icon` names. Status always pairs a shape (● ▲ ⬣) with text. Sheets are custom code (CSS scroll snap plus snap buttons), not a new library.
- **Data-viz rules:** (1) server-rendered SVG only, no chart library on first load; (2) every chart has a table twin; (3) units in titles and headers, tabular numerals; (4) gaps drawn as gaps, never interpolated; (5) derived values carry ≈ and a dashed stroke, stale ones are hatched with their age; (6) money in ink: billed in foreground, paid in success, owed in hatched warning; (7) at most four series, energy hues only for energy; (8) animation only for flow, only while fresh.
- **Accessibility beyond conformance:** sheets that snap by dragging also snap by buttons (WCAG 2.5.7); single-key shortcuts can be turned off (2.1.4); full-screen inspector screens, sheets and steps are history entries so Android Back closes them; typed confirmations open full screen on phones; the confirmation step is tested with voice control and switch access (trusted events come from both); live regions announce tray changes politely.

### 8.4 The six CLAUDE.md design-system rules

| Rule | What happens to it |
|---|---|
| 1. `<Chip>` / `<StatusChip>` | **Splits.** The tone mapping becomes contract-level with the remap in §8.1: the maps move to `src/lib/gui/fmt.ts`, so every status `Value` carries its tone into any GUI. "Use `<Chip>`, never a raw `<span>`" stays a Gridtop rule. |
| 2. Format primitives | **Becomes a contract guarantee.** The server formats every `Value.text` for locale, currency and zone, and `copy: "bare"` columns carry bare figures with the unit in the header, so the URA paste works in any GUI. `<Currency>`, `<Kwh>`, `<LocalDate>` remain Gridtop's renderers for client-side values, joined by `<Power>`, `<Percent>`, `<Ago>`. |
| 3. Token classes only | **Moves into Gridtop's rules**, tested over `src/gui/gridtop/**` and the host's auth pages. Other GUIs bring their own stylesheet. |
| 4. `<CopyTable>`, footer below | **Moves into Gridtop** as its period-review renderer, with cards on compact windows and virtualisation above 100 rows. The contract keeps what any GUI needs: `copy: "bare"` columns and totals below the rows. |
| 5. Destructive actions, `ClosePeriodDialog` | **Becomes host-owned.** The four confirmation levels are the host's `ConfirmStep` in code-owned `src/lib/gui/host`, enforced again by the routes and, for close, the database. `ConfirmDialog`'s and `ClosePeriodDialog`'s look becomes Gridtop's `Overlay`, with `max-h` and sheets on phones. "Never `confirm()`" stays. |
| 6. Picker / empty-state exclusion | **Splits.** The collection's `empty` state owns the create action, and conformance checks no switcher renders at zero rows. The inline-form-hides-empty-state rule stays in Gridtop. |

Gridtop adds three rules: every live value shows its age; anything prepared by a watcher or the assistant is dashed until recorded; every table has a compact rendering and scrolls sideways rather than clipping.

## 9. The assistant plugin

**Optional, off by default.** `assistant` is a non-core capability plugin. A missing `mgm_plugins` row means enabled in both TypeScript and SQL, so it needs `defaultEnabled: false` in the catalog plus a code-owned migration. While it is off, nothing of it reaches any GUI: no status item, Ask, tile, tools or JavaScript.

**Provider registry.** A root `llmProviders` service built like `meteringProviders`, with two adapter plugins, each a few hundred lines of `fetch`, `import "server-only"`, no SDK dependency: `openai-compatible` (OpenAI, DeepSeek, OpenRouter, self-hosted vLLM, Ollama, llama.cpp) and `anthropic`. The operator supplies the key as installation settings read at request time (`MGM_ASSISTANT_PROVIDER`, `_BASE_URL`, `_MODEL`, `_API_KEY`), never browser-visible. The base URL stays an installation setting, never an organization one. Per-organization keys under the DEK can follow; when they do, `llmProviders` joins `PRIVILEGED_REGISTRIES`. Tool schemas are zod 4 exported with `z.toJSONSchema()`.

**It runs as the signed-in person.** The kit pumps the loop through the contract's generic feed: `compose` posts the text and the current view as context, each result's `next` asks for another step, and `stop` ends it. Each `POST /api/assistant/threads/[id]/steps` is a write route of a non-core plugin, so it calls `gateRoute` with `assistant` against the thread's organization. It checks that the caller is the thread's author, composes afresh (so `getUser()` runs every step), makes one model call and at most three tool calls, and returns within the Hobby function limit. Stop is instant, a dropped 3G connection resumes from the last event, and nothing is held open. If a `composeAssistant` is added, it joins `COMPOSERS` and `SESSION_CHECKS`, and its service names join `REQUEST_SERVICES`. Read tools that compose billing with `readOnly` do not count as gating; the step route's own gate does.

**Organization binding.** A thread's organization is fixed on the server at creation and never taken from model output. Every tool call resolves its target organization and answers 403 unless it equals the thread's. Every proposal's params must resolve to the thread's organization. Reusing a result within a thread means re-serving that user's stored event after the current step's own authorization; nothing else is cached across requests.

**Tools are derived from the contract, deny by default:**

| Contract element | Tool | Behaviour |
|---|---|---|
| View with `agent: "read"`, `cost: "db"` | `view:<id>` (input: path params) | Through the page's own resolver: same loader, strict parse, gate, release, RLS, organization check. Output trimmed to priority-1 columns, 20 rows and totals |
| View with `agent: "read"`, `cost: "vendor"` | the same, marked external | Runs only against a person's Run event recorded on the server for that call; at most three per turn; budgeted |
| View without `agent: "read"` (all Configure and System views, credential entities, the send queue) | none | — |
| `ProposableAction` without `writeOnly` fields | `propose:<id>` (input: the action's schema) | Executes nothing; validates and records a proposal. External inputs carry no free text and no recipient; the route derives both |
| `humanOnly`, `typed` or `ceremony` | none | At most a hand-off link (an intent URL) |

The tool builder accepts only `ProposableAction` and re-filters at run time (`humanOnly === false`, `confirm` of `none` or `confirm`, no `writeOnly` fields). `assistant-human-only.test.ts` follows the assistant's import graph and fails if any module reaches a capability write method (`closePeriod`, `reconcile`, `ensureCheckout`, `handleNotification`, any delete) or a route file.

**Proposal lifecycle,** as append-only events: *drafted* → *shown* → *opened* → *submitted*, or *dismissed*, *expired* (14 days, or the target period closes), *stale* (the target view changed since drafting; the card greys and offers re-preview) or *failed* (the route refused; its code is shown). The link to the record lives only in the server-written *submitted* event: the proposable action's route calls a shared helper after its write succeeds, which reads the proposal under RLS and records *submitted* only when the proposal is in the caller's organization, names this action and entity, and is still open. Otherwise it ignores the `x-mgm-proposal` header and never fails the write. The audit tables are unchanged and their rows stay `actor_kind = human`, because the person makes the write. The routes that call the helper are exactly the proposable actions' routes (create period, generate bills, open-period regeneration, tariff drafts, refresh payment status, register devices, create payment link, send reminder).

**Audit.** `assistant_threads`, `assistant_events (thread_id, org_id, seq, kind, payload, created_at)` and `assistant_pseudonyms`. `authenticated` has SELECT only. Every row is written by `fn_assistant_append_event`, a SECURITY DEFINER function (`REVOKE EXECUTE … FROM PUBLIC, anon; GRANT … TO authenticated, service_role`) that derives `org_id` from the thread, the actor from `auth.uid()` and `seq` itself, and accepts the `assistant` and `system` author kinds only from the step route's server path. Budget counters are written the same way. A trigger rejects UPDATE and DELETE except inside the retention function. Database tests prove that a direct INSERT is refused and that an event's `org_id` always equals its thread's. Following DeepSeek Harness's "model-visible means logged", every model input, tool call and result, proposal and stop can be reconstructed, in the pseudonymised form the model saw.

**Privacy.** Threads are readable by their author and by super admins, not by every organization manager. Household names, account numbers, meter ids, addresses, email addresses, phone numbers and phone suffixes become per-thread tokens (H-01, A-01, M-01…) unless the organization opts in to names. The map lives on the server in `assistant_pseudonyms` under the same RLS, and the server de-pseudonymises the `Rich` text before the feed leaves it, so no GUI handles pseudonyms. Events are kept 180 days (an installation setting) and then removed by `fn_assistant_purge`, the only path the append-only trigger permits; the same function erases one person's threads when a super admin runs that human-only, typed action. Stored text is passed to the model as data: an address saying "ignore previous instructions" can at worst produce a bad proposal that a person reviews, because there is no write path.

**Cost and latency for Uganda.**
- **Small models by default:** settings recommend an inexpensive chat model or a self-hosted 7–14B model; the operator chooses.
- **Small contexts:** priority-1 columns, 20 rows, one organization, no raw rows or HTML.
- **Prefix caching:** a stable system prompt and deterministically ordered tool schemas, so provider prompt caches (DeepSeek's context cache, Anthropic's prompt caching, vLLM's prefix cache) hit across turns.
- **Budgets:** monthly per organization, 8 tool calls per turn, 15 s per tool; vendor reads need a person's Run.
- **Constrained connections:** steps arrive whole (there is no token streaming in v0), there is no automatic "brief me", and the assistant chunk (≤ 25 KB) loads only when the tile opens.
- **Latency:** a local acknowledgement within 300 ms and timed step lines; functions run in `bom1` beside the database, and a self-hosted model in the region avoids a second intercontinental hop.

**Hosted by any GUI.** The assistant contributes the `assistant.thread` feed (with `compose` and `stop`), the `assistant.proposal` record, a status item (`open: "/assistant"`, `ask`) and proposal and question signals (a question links to its thread, where it is answered). Gridtop docks the feed as its third tile; `gui-lite` links the status item to a page; a thread GUI could make the feed its home.

**MCP, later.** An `mcp-server` plugin may expose the same read and propose registry to outside agents. It uses MGM-issued tokens bound to one organization, scoped to read and propose, revocable, and resolved on the server like organization API tokens; never a Supabase access token, so an outside agent never holds PostgREST rights. The MCP route is released explicitly, has its own session-check entry and rate limits, and records an "agent on behalf of" actor on its proposals, which land in the person's GUI.

## 10. Delivery plan

#30 puts M2 recovery (#35) first and asks each scope change to name the outcome it unblocks. This redesign unblocks no 0.1 issue: #55's blockers (#35, #43–#47, #49–#51, #54) and #56's (#33, #44, #45, #50, #54) include no GUI work, and #61's acceptance (install, a synthetic bill and payment, backup, upgrade, restore) has no phone path. What P0 and P1 would do in 0.1 is avoid rework: #55's screenshots and #56's extension chapter would be written once against the shell that ships. So Aaron chooses (decision 1):

- **Option A (recommended while #35 is open): P0 only in 0.1.** Visible fixes, no contract, no migrations. P1 opens 0.2.
- **Option B: P0 and P1 in 0.1** as an explicit addition, with Hillary's agreement that #56 documents the contract as an appendix marked experimental, and with #55's screenshots taken after P1.

Under both options P2, the first phase with a migration, is 0.2, so the 0.1 candidate #58 accepts and #48's verified fresh-install baseline do not change. NFE's own adoption of each release stays a separate operator step (#53), and its database state is checked before the new close is relied on there. Every phase pauses cleanly between PRs. Every implementer prompt instructs `git commit -s` with the existing signing configuration.

**Who and how much.** Aaron, or agents he delegates to, implements; Hillary reviews every code-owned PR. At about half time for one person, P0–P4 is 26–33 weeks, roughly seven to eight months alongside M2–M6, of which only P0 (2–3 weeks) is proposed for 0.1. Each later phase starts only when the release it targets has no open blocker on #30 owned by the same person.

**Code-owned paths** need the other maintainer's approval on the final head: `/supabase/migrations/`, `/src/lib/roles.ts`, `/src/lib/runtime/`, `/src/lib/plugins/`, `/src/middleware.ts`, `/src/lib/mgm/released-routes.ts`, `/.github/workflows/`, `/.github/CODEOWNERS`, `/scripts/release/`. Decision 6 adds the GUI and assistant paths, each in the PR that creates its first file, because `codeowners.test.ts` requires each pattern to match a tracked file. Each phase batches its code-owned changes.

| Phase | Lands | PR-sized deliverables | Guard tests | Deleted | Code-owned |
|---|---|---|---|---|---|
| **P0 Visible fixes** (~2–3 wks) | 0.1 (A and B) | (1) Tokens into `@theme`, Inter, type scale, dark and energy tokens. (2) Token and type guards; raw colours fixed on login, merchant settings, payment history. (3) Sideways-scrolling tables, dialog `max-h`, sheets on compact, 44px coarse targets. (4) App manifest and icons, metadata, a styled "not in this release" page; `/payment/callback` as a static `route.ts`; plain HTML states on `/pay`. (5) `/microgrids/[id]` redirects to Operations once the operations PRs merge. (6) A PR template with a 360px check | `design-tokens.test.ts`, `no-arbitrary-font-size.test.ts`, `table-overflow.test.ts` | `tailwind.config.ts`, Inter Tight and JetBrains Mono, `payment-shell.tsx`, `review-bills.tsx`, `tenants`, the callback page | middleware, released routes, session-check entry for the callback |
| **P1 Contract and shell** (~4–5 wks) | 0.1 under B, else first in 0.2 | (1) `contract.ts`, `surface.ts`, `fmt.ts`, `mgmSurface` with its boot-time parse, GUI install plugins; `GateTarget` gains `household` and `paymentAttempt`. (2) The host: manifest, resolver, dispatcher, strict parse, merge, action host with `ConfirmStep` and `invoke`, `(gui)` layout and pages, `/api/gui/*`, kit, `KitView`, `RawView`, `ActionForm`. (3) `(dashboard)` renamed `(gui)` with its middleware, test and comment changes; legacy nav entries and `legacyPage()` for every unmigrated page; Gridtop Shell v0 (dock, context path, tray, bottom dock and sheets, ⌘K, pop-out, `/go/*`); the GUI stylesheet build; `MGM_GUI` check; CSP. (4) Catalog pages and aliases for billing, metering, payments; `schemas.ts` for the pilot's actions (grid tariffs, savings settings). (5) Pilot: the operations day view as `metering.site-day` with `billing.savings` attached; the fixture provider gains `getSiteSamples` behind `MGM_METERING_FIXTURES`. (6) `gui` Vitest project; budgets in `Build`; 360px Playwright smoke | Conformance kit and Gridtop's one-liner; matrix with `acme.poke`; host tests; `gui-boundary`; `gui-surface`; `installed-guis`; session-check `MODULES` and entries; credential canary test | `sidebar-nav*`, home quick-links, `(dashboard)/layout.tsx`, the operations legacy page. Per-page sub-navs, `TabNav` and `HierarchyNav` stay until their page migrates | runtime root and gate, catalog, released routes, middleware, workflows (budgets, Playwright), CODEOWNERS |
| **P2 The money path** (~5–7 wks) | 0.2 | (1) The close confirmation in **one commit**: the migration in §4.7 item 5, the route's 428 and 409, the legacy close button switched to the host's ceremony step (P1 precedes P2 under both options, so the ceremony comes from the host's `prepare` route), database tests, regenerated types, a component test that the legacy close succeeds. (2) `src/lib/billing/schemas.ts`, and the billing routes parse with it. (3) Billing views: periods, review (CopyTable renderer), history, checklist, send queue; every row of the parity table contributed. (4) Today with signals from billing, payments, metering and attached tiles; app badge. (5) Deletes for the screens it migrates, under the general rule, with method-aware release entries | `assistant-human-only` laid down early; 428, stale-409 and typed-name behavioural tests; a database test that only the close function closes a period, `service_role` included; ceremony host tests; delete routes classified in `route-surface.test.ts` | `BillingTable.tsx` (1,256 lines) and `BillingPeriodList.tsx` once the parity table is complete; legacy billing pages and their sub-navs; the org-card home | migrations, released routes, catalog |
| **P3 Live, payments, the rest, gui-lite** (~10–12 wks) | 0.3 | (1) `getLiveSnapshot` on the OpenEMS and fixture providers; `metering.live`. (2) `/payments?org=`. (3) Households (wizard form), household record, readings, devices and discovery, data source, tariff (tier and grid-tariff editors). (4) Settings, users, plugins, organizations, communities, invoice branding, payment accounts, each capability's routes moved onto its `schemas.ts`. (5) `gui-lite`; freeze contract 1.0 when both GUIs pass | Two-GUI matrix; stale-value tests; `writeOnly` fields never in prefill, examples or proposals | All remaining legacy pages, sub-navs and `HierarchyNav`; the wizard modal shell; `openems-backend-shell` and `invoice-shell` as page shells; raw payments components; `globals.css` beyond host pages | released routes, catalog, gate |
| **P4 The assistant** (~5–6 wks) | 0.3 or later | (a) Plugin, default-off migration, `llmProviders` with two adapters, threads, events and pseudonym tables with their writer and purge functions, read tools, feed and status, Explain. (b) Propose tools, the proposal view and lifecycle, the *submitted* helper, grounding, budgets | No tool from human-only, ceremony or unmarked views; organization binding for reads, proposals and reuse; direct INSERT refused; `org_id` equals the thread's; `scope-dispose`; session-check entries; the step route gated in `route-surface.test.ts` | — | migrations, runtime root, catalog, released routes |
| **P5 Field and desktop** | later | Service worker caching only the static shell and an offline page (never RSC payloads or `/api/gui/*`; never scoped over `/p/*`, `/pay`, `/payment/callback` or `/api/payments/*`; caches cleared on sign-out); offline reading drafts a person submits online through the normal route, money never queued; Window Controls Overlay polish; Realtime payments on desktop; per-person GUI choice; `mcp-server`; a thread-style GUI explored | Drafts never submit themselves; fresh session check on submit | The 30-second polling `live` hint on desktop, once Realtime replaces it | middleware |

## 11. Decisions for Aaron

1. **0.1 scope:** Option A, P0 only in 0.1, while #35 is open; Option B (P0 and P1, with Hillary's agreement on #56's experimental appendix) if M2 closes early. Justified as avoiding rework of #55's screenshots and #56's chapter, not as unblocking them. *Recommended: A.*
2. **The close confirmation:** the first commit of P2 in 0.2, together with the legacy close button. It is independent of the billing views and can ship on its own, giving every GUI and the legacy screen one reviewed-totals confirmation. *Recommended: yes.*
3. **Contract status:** "MGM GUI contract v0, experimental", recorded as ADR-005 worded by what it adds; 1.0 when Gridtop and `gui-lite` both pass. *Recommended: yes.*
4. **URLs:** none move; new paths are only added (`…/close`, `…/send`, `…/readings`, `/payments?org=`, `/assistant/*`, `/go/*`). *Recommended: yes.*
5. **Default GUI:** Gridtop; an unset `MGM_GUI` means `gridtop`; a per-person choice later. *Recommended: yes.*
6. **CODEOWNERS:** add `/src/lib/gui/contract.ts`, `/src/lib/gui/surface.ts`, `/src/lib/gui/host/`, `/src/lib/gui/conformance/`, `/src/gui/meta.ts`, `/src/gui/installed.ts`, `/src/lib/assistant/` and the guard tests' files, each in the PR that creates it; leave GUI packages and kit components unlisted. *Recommended: yes.*
7. **Confirmation steps are host-owned:** GUIs frame them through `Overlay` and tokens; bespoke forms apply only to `none` and `confirm` actions. *Recommended: yes* (the alternative, code-owning every GUI's ceremony files, puts each GUI behind code-owner review).
8. **Human-only list:** proposable are create period, generate bills, open-period regeneration, tariff drafts, refresh payment status, register devices, create payment link, send reminder; human-only are close, closed-period regeneration, manual usage, reconcile and adjustments, manual payment status, deletes, credentials, roles, plugin toggles, tokens, assistant erasure. *Recommended: as listed.*
9. **Send progress:** counted per phone in P2; a stored send record (a table, a gated route and a code-owned migration) only if Aaron wants the laptop to see it. *Recommended: per phone.*
10. **Assistant data:** installation-level provider settings; names pseudonymised by default, phone numbers never sent; threads visible to their author and super admins; 180-day retention. *Recommended: yes.*
11. **Proof of presence at close:** digest, acknowledgements and trusted input now; an optional AAL2 requirement via Supabase MFA later. *Recommended: defer AAL2.*
12. **Type and brand:** one variable Inter plus system mono; brand-hue safe range 200–340. *Recommended: yes.*
13. **Where the reasoning lives:** the contract in ADR-005 and, under option B, #56's appendix; the operator reasoning behind Gridtop (laptop and phone split, the Uganda connectivity budget, pilot feedback) on an NFE Library page written with the NFE Library skill and updated as phases ship. *Recommended: yes.*

## 12. Risks and mitigations

| Risk | Mitigation |
|---|---|
| The redesign takes hours from 0.1 | Option A keeps 0.1 to P0, with no contract and no migrations; P2 and its migration are 0.2, after #48's baseline and #58's acceptance |
| Code-owner review becomes a bottleneck | Batch catalog, root and released-route changes per phase; code-own only the contract, host, conformance and assistant paths |
| A page loses navigation mid-migration | Legacy nav entries and `legacyPage()`; each per-page nav is deleted only when its page migrates |
| Month-end tools are lost when `BillingTable` goes | The parity table in §7.3; the legacy page stays mounted until every row is contributed |
| Generic rendering drifts back to an admin template | Today, Live, the period review and the close are bespoke renderers from the start; generic kinds are the floor |
| The descriptor language grows into a UI framework | Complex flows go to bespoke forms or `flow`; a new kind or value type needs a host fallback and two plugins using it |
| Bespoke renderers and forms couple to plugin internals | Versioned `ext` and input schemas with declared `reads`; a mismatch falls back to the generic renderer or `ActionForm`; the matrix catches the rest |
| Action schemas drift from route parsing | One `schemas.ts` per capability, imported by the route and the surface; `gui-surface` checks the import |
| A GUI package carries something it should not | Import allowlist with a reviewed npm list, the boundary scan, host-owned confirmation steps with trusted-input checks, a CSP |
| The contract is wrong somewhere | Experimental through 0.x; `gui-lite` ships while change is cheap; 1.0 only after two GUIs pass |
| The catch-all adds latency on 3G | Signals out of the manifest; one organization per manifest; loaders on their own clients; enforced budgets (§4.8); functions in `bom1` |
| A mistyped `MGM_GUI` stops the whole process, payments included | Release verification and #55's guides check it before promotion; CI already fails a range mismatch |
| One GUI's CSS leaks into another | Stylesheets declared in metadata and linked only for the active GUI; CSS imports banned in GUI packages |
| Live data is slow or costly (OpenEMS in Europe, Hobby limits) | 10 s cache after authorization, polling only while visible, back-off on constrained connections, fixture provider for demos |
| Synthetic readings reach a real installation | The fixture provider registers only under `MGM_METERING_FIXTURES`; every Value and bill from it is labelled synthetic |
| jsdom conformance cannot see a 360px overflow | Source guards for overflow and target size, the PR template, and the 360px Playwright smoke |
| Web Share behaves differently per phone and target | Prefetch with `canShare`, the `wa.me` alternative, a real-device test before P2 closes |
| Assistant hazards: invented numbers, personal data, injected text, provider churn, cost | Grounded `Value`s and server-marked unverified figures; server-side pseudonyms; deny-by-default read tools; no write path; the `llmProviders` seam; budgets and small models |
| Public artifacts describe weaknesses | Issues, PRs, commits and the ADR describe this work only by what it adds, e.g. "the close function becomes the single path to a closed period and checks the reviewed totals" |

---

*Where this belongs:* if adopted, the contract and its rules go in `docs/adr/` (ADR-005) and, under option B, #56's plugin-author guide as an experimental appendix, worded as what they add. The operator-facing reasoning behind the default GUI (who uses MGM on which device, the Uganda connectivity figures, pilot feedback) has no home in this public repository; it is a good candidate for an NFE Library page at bookstack.nearlyfreeenergy.com, written with the NFE Library skill so the team can keep it current as each phase ships.

---

## Appendix A. The type-check

`tsc-check/tsconfig.json` (strict, `moduleResolution: "bundler"`, `skipLibCheck`, `jsx: "react-jsx"`, paths to the repository's `node_modules` for `react`, `zod`, `cordis` and `@supabase/supabase-js`) compiles `contract.ts`, a stand-in kit module that declares `GuiKit`, the billing contribution (5a), the SMS-reminders schemas, contribution and platform plugin (5b), `gui-lite`'s Shell, metadata and plugin object (5c), and `negative.ts`, whose twelve `@ts-expect-error` cases must each fail. Two stubs mirror repository types: `MgmPluginName` (with the `assistant` and `sms-reminders` names this proposal adds) and `UserRoleRecord`. Result of `node_modules/.bin/tsc -p tsc-check`: exit 0, no diagnostics.

## Appendix B. Review notes

Three reviews were applied in full. Where they differed, the decisions already taken governed:

- **Confirmation steps.** The decision put typed and ceremony enforcement in the routes. To make what a person sees the same as what the route receives, the steps also became host-owned in code-owned paths and accept only trusted input; GUIs frame them through `Overlay`, so GUI packages stay outside CODEOWNERS as decided.
- **The import rule.** The decided deny-list guard stays; an import allowlist with a reviewed npm list was added on top, which admits Gridtop's dependencies and replaces `next/*` with kit wrappers.
- **Agent reads.** "Views become read tools" now needs an explicit `agent: "read"`, refused for Configure and System views.
- **Assistant protocol.** Hosted through the generic feed (`compose`, `stop`, `ActionResult.next`, `Rich`), so any GUI can host it without assistant-specific types.
- **Send queue.** "One human tap per household" is now described honestly; progress is per phone.
- **#30.** P0 and P1 avoid rework rather than unblock anything; option A keeps 0.1 small, and every migration is 0.2.
- **Fixture readings** are labelled synthetic and the provider registers only under an installation setting, rather than being refused for bills, because #61's acceptance needs a synthetic bill.
- **Names.** The host's fallback renderer is `RawView`, so it is not confused with Gridtop's inspector tile; one identifier names each GUI.
- **Estimates** were raised to 26–33 half-time weeks.
