From 39bf1cf4c0718a05c653e0477cb7e3a9af7d5fd1 Mon Sep 17 00:00:00 2001
From: Omer Celik <omer@omercelik.dev>
Date: Mon, 27 Jul 2026 15:07:53 +0300
Subject: [PATCH 1/2] =?UTF-8?q?feat(console):=20TODAY=20=E2=80=94=20the=20?=
 =?UTF-8?q?estate's=20triage=20screen,=20and=20the=20shell=20moves=20up=20?=
 =?UTF-8?q?to=20hold=20it?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

U4 slice 2 (console RFC D2). An operator opens a console to answer "is anything wrong",
not to browse: the landing screen is now the whole estate's triage, and every module is
one click away rather than the front door.

The shell had to move up for that. It now belongs to the console as a whole (services,
nav, Today) and a service contributes only its panels — which also means capability
discovery runs for EVERY registry service, not just the open one: triage cannot speak for
a service whose capabilities it never asked about.

What the screen refuses to do is the design:
- it invents no aggregate the contract does not expose — every number is read from the
  same take-bounded lists an operator would open, so the screen PRINTS its scope rather
  than implying it saw everything;
- a surface it CANNOT read (no ops role, or a call it cannot scope) is a row of its own,
  grouped per service: blindness during an incident is the most important thing an
  operator can be told, and five identical rows would bury the real problems;
- a surface that dies mid-read is a row too — triage never drops a service quietly;
- "nothing found" is said as "nothing in the most recent 50 rows of each surface", which
  is a different claim from "nothing is wrong", and the console must not make the second.

Each row is a deep link: it opens the panel that owns it, ON the service that owns it.

Proof: the smoke's three apps — the open one reports the gate a real upload created (and
the row deep-links into its panel), the auth-floored and tenant-scoped ones report that
the console cannot see them, in the server's own words. 18/18 green, accessibility of the
landing screen included; console unit 136.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 docs/rfc/goldpath-console.md       |   8 +
 ui/console/e2e/a11y.spec.ts        |   3 +
 ui/console/e2e/console.spec.ts     |  41 ++++
 ui/console/src/Console.test.tsx    | 105 ----------
 ui/console/src/Console.tsx         | 122 -----------
 ui/console/src/ConsoleApp.test.tsx |  96 ++++++++-
 ui/console/src/ConsoleApp.tsx      | 108 +++++++---
 ui/console/src/TriageHome.tsx      | 104 ++++++++++
 ui/console/src/index.ts            |   4 +-
 ui/console/src/sections.test.tsx   |  57 ++++++
 ui/console/src/sections.tsx        |  78 ++++++++
 ui/console/src/triage.test.ts      | 312 +++++++++++++++++++++++++++++
 ui/console/src/triage.ts           | 234 ++++++++++++++++++++++
 13 files changed, 1018 insertions(+), 254 deletions(-)
 delete mode 100644 ui/console/src/Console.test.tsx
 delete mode 100644 ui/console/src/Console.tsx
 create mode 100644 ui/console/src/TriageHome.tsx
 create mode 100644 ui/console/src/sections.test.tsx
 create mode 100644 ui/console/src/sections.tsx
 create mode 100644 ui/console/src/triage.test.ts
 create mode 100644 ui/console/src/triage.ts

diff --git a/docs/rfc/goldpath-console.md b/docs/rfc/goldpath-console.md
index 713317b..3360945 100644
--- a/docs/rfc/goldpath-console.md
+++ b/docs/rfc/goldpath-console.md
@@ -66,6 +66,14 @@ custom-develop ON, with the same kit, the same way they add features to the back
   Red/overrun-predicted runs, repair-queue depths, gates awaiting four-eyes, DLQ depth —
   each row deep-links into its panel. Fleet browsing is one click away, never the
   landing page: operators open consoles to answer "is anything wrong", not to browse.
+  SHIPPED (U4, 2026-07-27). Three properties it must keep, because each was a decision:
+  (a) every number comes from the contract's own take-bounded lists — the console invents
+  no aggregate the API does not expose — so the screen PRINTS its scope instead of
+  implying completeness; (b) a surface the console cannot read (no ops role, or a call it
+  cannot scope) is itself a row: blindness during an incident is the most important thing
+  an operator can be told, and it is grouped per service so it cannot bury the estate's
+  real problems; (c) a surface that dies mid-read is a row too — triage never drops a
+  service quietly.
 - **D3 — Proof bar (UI is claims-are-proofs too).**
   Kit: component tests (vitest) on the composites (keyset table paging, verb button's
   refusal surface, state mapping), under a COVERAGE FLOOR that CI enforces (kit 95/90,
diff --git a/ui/console/e2e/a11y.spec.ts b/ui/console/e2e/a11y.spec.ts
index f929fe9..51de6ad 100644
--- a/ui/console/e2e/a11y.spec.ts
+++ b/ui/console/e2e/a11y.spec.ts
@@ -13,6 +13,8 @@ import { expect, test } from "@playwright/test";
 const service = process.env.GOLDPATH_SERVICE_URL ?? "http://localhost:5310";
 
 const PANELS = [
+  // The landing screen is checked too: it is the one an operator sees first, at 3am.
+  { nav: "Today", ready: "triage-home" },
   { nav: "Runs", ready: "run-console" },
   { nav: "Bulk intake", ready: "bulk-panel" },
   { nav: "Campaigns", ready: "campaign-panel" },
@@ -40,6 +42,7 @@ test.describe("the console is operable without a mouse or a perfect screen", ()
 
   test("a confirm dialog is opened, dismissed and handed back by keyboard alone", async ({ page }) => {
     await page.goto(`/?base=${encodeURIComponent(service)}`);
+    await page.getByRole("button", { name: "Runs" }).click();
     await expect(page.getByTestId("run-console")).toBeVisible();
 
     // Opened from the KEYBOARD — the mouse is not assumed anywhere in this journey.
diff --git a/ui/console/e2e/console.spec.ts b/ui/console/e2e/console.spec.ts
index af840e9..33def0a 100644
--- a/ui/console/e2e/console.spec.ts
+++ b/ui/console/e2e/console.spec.ts
@@ -24,6 +24,7 @@ test.describe("the run console against a real Goldpath app", () => {
 
   test("triggers a job, watches the run finish, and replays its repair item", async ({ page }) => {
     await page.goto(`/?base=${encodeURIComponent(service)}`);
+    await page.getByRole("button", { name: "Runs" }).click();
 
     // The fleet appears because the executor exists (zero-config discovery).
     await expect(page.getByRole("button", { name: /console-smoke/ })).toBeVisible();
@@ -41,6 +42,7 @@ test.describe("the run console against a real Goldpath app", () => {
     const smokeRun = page.getByRole("row", { name: /SmokeJob/ }).first();
     await expect(async () => {
       await page.goto(`/?base=${encodeURIComponent(service)}`);
+      await page.getByRole("button", { name: "Runs" }).click();
       await expect(smokeRun).toContainText("Completed", { timeout: 5_000 });
     }).toPass({ timeout: 60_000 });
 
@@ -323,10 +325,16 @@ test.describe("the run console against a real Goldpath app", () => {
   test("behind the auth floor: every surface is NAMED as forbidden, never hidden", async ({ page }) => {
     await page.goto(`/?base=${encodeURIComponent(secured)}`);
 
+    // TRIAGE says it first: the console cannot see this service, which during an incident
+    // is the most important thing an operator can be told.
+    await expect(page.getByTestId("triage-home")).toContainText("surfaces on");
+    await expect(page.getByTestId("triage-home")).toContainText("cannot be read");
+
     // The modules ARE composed here; the operator simply has no principal. Hiding them
     // would tell the operator this app has no admin surface, which is false.
     await expect(page.getByRole("button", { name: "Runs" })).toBeVisible();
     await expect(page.getByRole("button", { name: "Bulk intake" })).toBeVisible();
+    await page.getByRole("button", { name: "Runs" }).click();
     await expect(page.getByRole("alert")).toContainText("lacks the ops role");
     await expect(page.getByTestId("run-console")).toHaveCount(0);
     await expect(page.getByText(/No Goldpath admin surface answered here/)).toHaveCount(0);
@@ -337,7 +345,9 @@ test.describe("the run console against a real Goldpath app", () => {
 
     // R1: no ambient tenant → the app refuses (400). The console must repeat that, not
     // silently downgrade a composed module to "absent".
+    await expect(page.getByTestId("triage-home")).toContainText("cannot be read");
     await expect(page.getByRole("button", { name: "Runs" })).toBeVisible();
+    await page.getByRole("button", { name: "Runs" }).click();
     const banner = page.getByRole("alert");
     await expect(banner).toContainText("composed here but refused this request");
     await expect(banner).toContainText(/tenant/i);
@@ -346,6 +356,7 @@ test.describe("the run console against a real Goldpath app", () => {
 
   test("a service that dies MID-SESSION is reported, not papered over", async ({ page }) => {
     await page.goto(`/?base=${encodeURIComponent(service)}`);
+    await page.getByRole("button", { name: "Runs" }).click();
     await expect(page.getByTestId("run-console")).toBeVisible();
 
     // The console discovered a healthy service; now the service stops answering.
@@ -365,6 +376,10 @@ test.describe("the run console against a real Goldpath app", () => {
     // console reads theirs.
     await page.goto("/");
 
+    // The landing screen is TODAY; the modules are one click away, never the front door.
+    await expect(page.getByTestId("triage-home")).toBeVisible();
+    await page.getByRole("button", { name: "Runs" }).click();
+
     const picker = page.getByLabel(/service/i);
     await expect(picker).toHaveValue("open");
     await expect(page.getByTestId("run-console")).toBeVisible();
@@ -384,4 +399,30 @@ test.describe("the run console against a real Goldpath app", () => {
     await picker.selectOption("open");
     await expect(page.getByTestId("run-console")).toBeVisible();
   });
+
+  test("triage: the first screen answers 'is anything wrong' and its rows are deep links", async ({ page }) => {
+    // Give the estate something to report: a batch waiting at the four-eyes gate.
+    const csv = "EndToEndId,Amount\nE2E-T1,10.00\nE2E-T2,20.00\n";
+    const uploaded = await page.request.post(`${service}/goldpath/admin/bulk/batches/payments?fileName=triage.csv`, {
+      headers: { "content-type": "application/octet-stream" },
+      data: csv,
+    });
+    expect(uploaded.ok()).toBeTruthy();
+
+    // The validate job is real; poll the TRIAGE screen until it reports the gate.
+    const gate = page.getByRole("button", { name: /awaiting approval in payments/ });
+    await expect(async () => {
+      await page.goto("/");
+      await expect(page.getByTestId("triage-home")).toBeVisible({ timeout: 5_000 });
+      await expect(gate).toBeVisible({ timeout: 5_000 });
+    }).toPass({ timeout: 90_000 });
+
+    // The screen says WHAT it read, so the numbers cannot be mistaken for the whole truth.
+    await expect(page.getByTestId("triage-home")).toContainText("most recent 50 rows");
+
+    // A row is a deep link: it opens the panel that owns it, on the service that owns it.
+    await gate.click();
+    await expect(page.getByTestId("bulk-panel")).toBeVisible();
+    await expect(page.getByLabel(/service/i)).toHaveValue("open");
+  });
 });
diff --git a/ui/console/src/Console.test.tsx b/ui/console/src/Console.test.tsx
deleted file mode 100644
index 4256d6a..0000000
--- a/ui/console/src/Console.test.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { Console } from "./Console";
-
-const json = (body: unknown, status = 200) =>
-  new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
-
-/** A service that answers ONLY the given probe roots — everything else is 404 (absent). */
-function service(present: Record<string, number | undefined>) {
-  return (async (input: RequestInfo | URL) => {
-    const url = String(input);
-    const hit = Object.keys(present).find((route) => url.includes(route));
-    if (!hit) return new Response("not found", { status: 404 });
-    const status = present[hit] ?? 200;
-    if (status !== 200) return new Response("", { status });
-    if (url.includes("/fleets")) return json([{ schedulerName: "it-cluster", jobCount: 1, nodes: [] }]);
-    return json([]);
-  }) as typeof fetch;
-}
-
-describe("the console shell (capability discovery decides what EXISTS)", () => {
-  it("shows only the sections the service actually composes", async () => {
-    render(<Console fetcher={service({ "/jobs/fleets": 200, "/bulk/definitions": 200 })} />);
-
-    expect(await screen.findByRole("button", { name: "Runs" })).toBeInTheDocument();
-    expect(screen.getByRole("button", { name: "Bulk intake" })).toBeInTheDocument();
-    expect(screen.queryByRole("button", { name: "Campaigns" })).toBeNull();     // never composed
-    expect(screen.queryByRole("button", { name: "Archival" })).toBeNull();
-  });
-
-  it("a forbidden capability is NAMED, not hidden — the operator learns why", async () => {
-    render(<Console fetcher={service({ "/jobs/fleets": 403 })} />);
-
-    expect(await screen.findByRole("button", { name: "Runs" })).toBeInTheDocument();
-    expect(await screen.findByRole("alert")).toHaveTextContent(/lacks the ops role/i);
-  });
-
-  it("a service with no Goldpath surface says so instead of showing an empty frame", async () => {
-    render(<Console fetcher={service({})} />);
-
-    expect(await screen.findByText(/No Goldpath admin surface answered here/)).toBeInTheDocument();
-  });
-
-  it("lands on the archival panel when archival is the only composed module", async () => {
-    render(<Console fetcher={service({ "/archival/definitions": 200 })} />);
-
-    expect(await screen.findByTestId("archival-panel")).toBeInTheDocument();
-    expect(screen.getByRole("button", { name: "Archival" })).toHaveAttribute("aria-current", "page");
-  });
-
-  it("lands on the notification evidence panel when notification is the only composed module", async () => {
-    render(<Console fetcher={service({ "/notification/templates": 200 })} />);
-
-    expect(await screen.findByTestId("notification-panel")).toBeInTheDocument();
-  });
-
-  it("lands on the campaign governor when campaign is the only composed module", async () => {
-    render(<Console fetcher={service({ "/campaign/": 200 })} />);
-
-    expect(await screen.findByTestId("campaign-panel")).toBeInTheDocument();
-  });
-
-  it("lands on the bulk panel when bulk is the only composed module", async () => {
-    render(<Console fetcher={service({ "/bulk/definitions": 200 })} />);
-
-    expect(await screen.findByTestId("bulk-panel")).toBeInTheDocument();
-  });
-
-  it("lands on the run console when jobs is present", async () => {
-    render(<Console fetcher={service({ "/jobs/fleets": 200 })} />);
-
-    expect(await screen.findByTestId("run-console")).toBeInTheDocument();
-  });
-
-  it("a REFUSED capability is named with the server's reason — never hidden as absent", async () => {
-    // A multi-tenant app with no ambient tenant: every surface answers 400. The modules
-    // are composed; only this request cannot be scoped. Calling that "absent" would tell
-    // the operator the app does not have the module at all.
-    const fetcher = (async () =>
-      new Response(JSON.stringify({ title: "Tenant could not be resolved." }), {
-        status: 400,
-        headers: { "content-type": "application/json" },
-      })) as typeof fetch;
-
-    render(<Console fetcher={fetcher} />);
-
-    expect(await screen.findByRole("button", { name: "Runs" })).toBeInTheDocument();
-    const banner = await screen.findByRole("alert");
-    expect(banner).toHaveTextContent("Runs is composed here but refused this request");
-    expect(banner).toHaveTextContent("Tenant could not be resolved.");
-    expect(screen.queryByText(/No Goldpath admin surface answered here/)).toBeNull();
-    expect(screen.queryByTestId("run-console")).toBeNull();
-  });
-
-  it("a forbidden capability repeats the server's words when it gave any", async () => {
-    const fetcher = (async () =>
-      new Response(JSON.stringify({ message: "the 'goldpath-ops' role is required" }), {
-        status: 403,
-        headers: { "content-type": "application/json" },
-      })) as typeof fetch;
-
-    render(<Console fetcher={fetcher} />);
-
-    expect(await screen.findByRole("alert")).toHaveTextContent("the 'goldpath-ops' role is required");
-  });
-});
diff --git a/ui/console/src/Console.tsx b/ui/console/src/Console.tsx
deleted file mode 100644
index 31e616d..0000000
--- a/ui/console/src/Console.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import { useEffect, useMemo, useState } from "react";
-import { AppShell, Banner } from "@goldpath/kit";
-import type { ShellNavItem } from "@goldpath/kit";
-import { AdminClient, MODULES, type Capability, type ModuleName } from "./adminClient";
-import { RunConsole } from "./RunConsole";
-import { BulkPanel } from "./BulkPanel";
-import { CampaignPanel } from "./CampaignPanel";
-import { NotificationPanel } from "./NotificationPanel";
-import { ArchivalPanel } from "./ArchivalPanel";
-
-export interface ConsoleProps {
-  /** Service root; omit for same-origin (the console is served BY the app it drives). */
-  baseUrl?: string;
-  title?: string;
-  fetcher?: typeof fetch;
-  now?: Date;
-  /** The registry's other services — omitted entirely when there is only one. */
-  services?: string[];
-  activeService?: string;
-  onSelectService?: (name: string) => void;
-}
-
-type Capabilities = Record<ModuleName, Capability>;
-
-const SECTION_LABEL: Record<ModuleName, string> = {
-  jobs: "Runs",
-  archival: "Archival",
-  bulk: "Bulk intake",
-  notification: "Notifications",
-  campaign: "Campaigns",
-};
-
-/**
- * The console shell: capability discovery decides what EXISTS, the shell renders only
- * that (console RFC §2). A module the app never composed is an absent section — never a
- * dead link, never a manifest upload. A capability that is present but REFUSING — no ops
- * role, or no tenant to scope the call to — says exactly that, in the server's words:
- * "absent" is reserved for a module the app genuinely does not compose.
- */
-export function Console({
-  baseUrl,
-  title = "Goldpath console",
-  fetcher,
-  now,
-  services,
-  activeService,
-  onSelectService,
-}: ConsoleProps) {
-  const client = useMemo(() => new AdminClient({ baseUrl, fetcher }), [baseUrl, fetcher]);
-  const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
-  const [section, setSection] = useState<ModuleName>("jobs");
-  const [collapsed, setCollapsed] = useState(false);
-
-  useEffect(() => {
-    let live = true;
-    void client.discoverCapabilities().then((found) => {
-      if (!live) return;
-      setCapabilities(found);
-      const first = MODULES.find((module) => found[module].kind !== "absent");
-      if (first) setSection(first);
-    });
-    return () => {
-      live = false;
-    };
-  }, [client]);
-
-  const nav: ShellNavItem[] = capabilities
-    ? MODULES.filter((module) => capabilities[module].kind !== "absent").map((module) => ({
-        id: module,
-        label: SECTION_LABEL[module],
-        onSelect: () => setSection(module),
-      }))
-    : [];
-
-  return (
-    <AppShell
-      title={title}
-      nav={nav}
-      activeId={section}
-      services={services?.map((name) => ({ name, onSelect: () => onSelectService?.(name) }))}
-      activeService={activeService}
-      collapsed={collapsed}
-      onToggleCollapsed={() => setCollapsed(!collapsed)}
-    >
-      {capabilities === null && <p className="text-sm text-muted-foreground">Discovering capabilities…</p>}
-
-      {capabilities !== null && nav.length === 0 && (
-        <p className="text-sm text-muted-foreground">
-          No Goldpath admin surface answered here — this app composes none, or the service is unreachable.
-        </p>
-      )}
-
-      {capabilities?.[section].kind === "forbidden" && (
-        <Banner tone="warning">
-          {SECTION_LABEL[section]} exists on this service, but your account lacks the ops role for it.
-          {capabilities[section].message ? ` The service said: “${capabilities[section].message}”` : ""}
-        </Banner>
-      )}
-
-      {capabilities?.[section].kind === "refused" && (
-        // Composed, reachable, and REFUSING — the operator needs the server's reason
-        // (a multi-tenant app scopes admin calls to the ambient tenant), not a blank screen.
-        <Banner tone="warning">
-          {SECTION_LABEL[section]} is composed here but refused this request.
-          {capabilities[section].message ? ` The service said: “${capabilities[section].message}”` : ""}
-        </Banner>
-      )}
-
-      {capabilities?.[section].kind === "present" && section === "jobs" && (
-        <RunConsole client={client} now={now} />
-      )}
-
-      {capabilities?.[section].kind === "present" && section === "bulk" && <BulkPanel client={client} />}
-
-      {capabilities?.[section].kind === "present" && section === "campaign" && <CampaignPanel client={client} />}
-
-      {capabilities?.[section].kind === "present" && section === "notification" && <NotificationPanel client={client} />}
-
-      {capabilities?.[section].kind === "present" && section === "archival" && <ArchivalPanel client={client} />}
-    </AppShell>
-  );
-}
diff --git a/ui/console/src/ConsoleApp.test.tsx b/ui/console/src/ConsoleApp.test.tsx
index 11201c6..7263cca 100644
--- a/ui/console/src/ConsoleApp.test.tsx
+++ b/ui/console/src/ConsoleApp.test.tsx
@@ -45,13 +45,22 @@ function estate(options: { registry?: unknown; registryStatus?: number } = {}) {
 }
 
 describe("the console across services", () => {
-  it("lists the registry's services and lands on the first", async () => {
+  it("lands on TODAY — the operator's question is 'is anything wrong', not 'what exists'", async () => {
+    const { fetcher } = estate();
+    render(<ConsoleApp fetcher={fetcher} search="" />);
+
+    expect(await screen.findByTestId("triage-home")).toBeInTheDocument();
+    expect(screen.getByRole("button", { name: "Today" })).toHaveAttribute("aria-current", "page");
+  });
+
+  it("lists the registry's services and navigates the first one's modules", async () => {
     const { fetcher } = estate();
     render(<ConsoleApp fetcher={fetcher} search="" />);
 
     const picker = await screen.findByLabelText(/service/i);
     expect(picker).toHaveValue("payments");
-    expect(await screen.findByRole("button", { name: "Bulk intake" })).toBeInTheDocument();
+    await userEvent.click(await screen.findByRole("button", { name: "Bulk intake" }));
+    expect(await screen.findByTestId("bulk-panel")).toBeInTheDocument();
   });
 
   it("switching service RE-DISCOVERS — one service's panels never appear under another's name", async () => {
@@ -70,6 +79,52 @@ describe("the console across services", () => {
     expect(asked.some((url) => url.startsWith("https://claims.internal"))).toBe(true);
   });
 
+  it("a forbidden capability is NAMED with the server's words", async () => {
+    const fetcher = (async (input: RequestInfo | URL) => {
+      const url = String(input);
+      if (url.includes("console.config.json")) return new Response("", { status: 404 });
+      return new Response(JSON.stringify({ message: "the 'goldpath-ops' role is required" }), {
+        status: 403,
+        headers: { "content-type": "application/json" },
+      });
+    }) as typeof fetch;
+
+    render(<ConsoleApp fetcher={fetcher} search="" />);
+
+    await userEvent.click(await screen.findByRole("button", { name: "Runs" }));
+    expect(await screen.findByText(/lacks the ops role/)).toHaveTextContent("the 'goldpath-ops' role is required");
+  });
+
+  it("a REFUSED capability is named too — composed, reachable, and saying no", async () => {
+    const fetcher = (async (input: RequestInfo | URL) => {
+      const url = String(input);
+      if (url.includes("console.config.json")) return new Response("", { status: 404 });
+      return new Response(JSON.stringify({ title: "Tenant could not be resolved." }), {
+        status: 400,
+        headers: { "content-type": "application/json" },
+      });
+    }) as typeof fetch;
+
+    render(<ConsoleApp fetcher={fetcher} search="" />);
+
+    await userEvent.click(await screen.findByRole("button", { name: "Runs" }));
+    expect(await screen.findByText(/composed here but refused this request/)).toHaveTextContent("Tenant could not be resolved.");
+  });
+
+  it("a service with NO Goldpath surface says so instead of an empty frame", async () => {
+    const fetcher = (async (input: RequestInfo | URL) => {
+      const url = String(input);
+      if (url.includes("console.config.json")) return new Response("", { status: 404 });
+      return new Response("", { status: 404 });
+    }) as typeof fetch;
+
+    render(<ConsoleApp fetcher={fetcher} search="" />);
+
+    // Today still answers — it just has nothing to report from a service that composes none.
+    expect(await screen.findByTestId("triage-home")).toBeInTheDocument();
+    expect(screen.queryByRole("button", { name: "Runs" })).toBeNull();
+  });
+
   it("a single-service console shows no picker at all — nothing to choose between", async () => {
     const fetcher = (async (input: RequestInfo | URL) => {
       const url = String(input);
@@ -83,6 +138,43 @@ describe("the console across services", () => {
     expect(screen.queryByLabelText(/service/i)).toBeNull();
   });
 
+  it("a triage row opens the panel it belongs to, on the SERVICE it belongs to", async () => {
+    const fetcher = (async (input: RequestInfo | URL) => {
+      const url = String(input);
+      const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
+      if (url.includes("console.config.json")) {
+        return json({
+          services: [
+            { name: "payments", adminBaseUrl: "https://payments.internal" },
+            { name: "claims", adminBaseUrl: "https://claims.internal" },
+          ],
+        });
+      }
+
+      // Only CLAIMS has something wrong: a batch waiting at its four-eyes gate.
+      if (url.startsWith("https://claims.internal")) {
+        if (url.includes("/bulk/definitions")) {
+          return json([{ name: "payouts", batchesByState: { Validated: 1 }, awaitingApproval: 1, oldestAwaitingApprovalSeconds: 7200 }]);
+        }
+
+        if (url.includes("/bulk/batches")) return json([]);
+        return new Response("", { status: 404 });
+      }
+
+      if (url.includes("/jobs/fleets")) return json([]);
+      return new Response("", { status: 404 });
+    }) as typeof fetch;
+
+    render(<ConsoleApp fetcher={fetcher} search="" />);
+
+    const row = await screen.findByRole("button", { name: /awaiting approval in payouts/ });
+    await userEvent.click(row);
+
+    // The console switched service AND section — the row is a deep link, not a label.
+    expect(await screen.findByTestId("bulk-panel")).toBeInTheDocument();
+    expect(screen.getByLabelText(/service/i)).toHaveValue("claims");
+  });
+
   it("a registry that failed to load is ANNOUNCED — an operator who configured four services must not silently see one", async () => {
     const { fetcher } = estate({ registryStatus: 500 });
     render(<ConsoleApp fetcher={fetcher} search="" />);
diff --git a/ui/console/src/ConsoleApp.tsx b/ui/console/src/ConsoleApp.tsx
index 454c64b..ed3ef4e 100644
--- a/ui/console/src/ConsoleApp.tsx
+++ b/ui/console/src/ConsoleApp.tsx
@@ -1,28 +1,34 @@
-import { useEffect, useState } from "react";
-import { Banner } from "@goldpath/kit";
-import { Console } from "./Console";
+import { useEffect, useMemo, useState } from "react";
+import { AppShell, Banner } from "@goldpath/kit";
+import type { ShellNavItem } from "@goldpath/kit";
+import { AdminClient, type ModuleName } from "./adminClient";
+import { composedSections, SECTION_LABEL, ServicePanels, type Capabilities } from "./sections";
+import { TriageHome } from "./TriageHome";
 import { loadRegistry, SAME_ORIGIN, type ServiceEntry } from "./registry";
 
 export interface ConsoleAppProps {
+  title?: string;
   fetcher?: typeof fetch;
   /** Injected in tests; defaults to the browser's own query string. */
   search?: string;
   now?: Date;
 }
 
+/** The landing section is the estate, not a module — hence its own id. */
+const TODAY = "today";
+type Section = typeof TODAY | ModuleName;
+
 /**
- * The console, across services (console RFC §3 + D2). It owns exactly two things the
- * per-service screen must not: WHICH services exist (config, not discovery) and which one
- * the operator is looking at.
- *
- * Each service gets its own `Console` instance, keyed by name, so switching re-runs
- * capability discovery from scratch — a payments service's panels must never be shown
- * under a claims service's name because the shell happened to reuse the component.
+ * The console (console RFC §3 + D2). It owns what a single service cannot: WHICH services
+ * exist (config, not discovery), which one the operator is looking at, and the triage home
+ * that answers "is anything wrong" across all of them before any of them is opened.
  */
-export function ConsoleApp({ fetcher, search, now }: ConsoleAppProps) {
+export function ConsoleApp({ title = "Goldpath console", fetcher, search, now }: ConsoleAppProps) {
   const [services, setServices] = useState<ServiceEntry[] | null>(null);
   const [problem, setProblem] = useState<{ text: string; fellBack: boolean } | null>(null);
   const [active, setActive] = useState<string | null>(null);
+  const [section, setSection] = useState<Section>(TODAY);
+  const [collapsed, setCollapsed] = useState(false);
 
   useEffect(() => {
     let live = true;
@@ -37,14 +43,64 @@ export function ConsoleApp({ fetcher, search, now }: ConsoleAppProps) {
     };
   }, [fetcher, search]);
 
+  // One client per service, kept STABLE across renders: the capability hook keys off it,
+  // and a fresh client every render would re-probe every service forever.
+  const clients = useMemo(() => {
+    const map = new Map<string, AdminClient>();
+    for (const service of services ?? []) {
+      map.set(service.name, new AdminClient({ baseUrl: service.adminBaseUrl, fetcher }));
+    }
+
+    return map;
+  }, [services, fetcher]);
+
+  // EVERY service is probed, not just the open one: the triage home speaks for the whole
+  // estate, and a service whose capabilities are unknown is a service it cannot speak for.
+  const [discovered, setDiscovered] = useState<Map<string, Capabilities>>(new Map());
+
+  useEffect(() => {
+    let live = true;
+    setDiscovered(new Map());
+    void Promise.all(
+      [...clients].map(async ([name, entry]) => [name, await entry.discoverCapabilities()] as const),
+    ).then((entries) => live && setDiscovered(new Map(entries)));
+    return () => {
+      live = false;
+    };
+  }, [clients]);
+
+  const service = (services ?? []).find((entry) => entry.name === active) ?? services?.[0] ?? SAME_ORIGIN;
+  const client = clients.get(service.name) ?? new AdminClient({ baseUrl: service.adminBaseUrl, fetcher });
+  const capabilities = discovered.get(service.name) ?? null;
+
   if (services === null) {
     return <p className="p-6 text-sm text-muted-foreground">Reading the service registry…</p>;
   }
 
-  const service = services.find((entry) => entry.name === active) ?? services[0] ?? SAME_ORIGIN;
+  const nav: ShellNavItem[] = [
+    { id: TODAY, label: "Today", onSelect: () => setSection(TODAY) },
+    ...composedSections(capabilities).map((module) => ({
+      id: module,
+      label: SECTION_LABEL[module],
+      onSelect: () => setSection(module),
+    })),
+  ];
+
+  const open = (target: string, targetSection: ModuleName) => {
+    setActive(target);
+    setSection(targetSection);
+  };
 
   return (
-    <>
+    <AppShell
+      title={title}
+      nav={nav}
+      activeId={section}
+      services={services.length > 1 ? services.map((entry) => ({ name: entry.name, onSelect: () => setActive(entry.name) })) : undefined}
+      activeService={service.name}
+      collapsed={collapsed}
+      onToggleCollapsed={() => setCollapsed(!collapsed)}
+    >
       {problem && (
         // Config that failed to load is NOT a quiet fallback: an operator who configured
         // four services and sees one is looking at the wrong console.
@@ -53,16 +109,20 @@ export function ConsoleApp({ fetcher, search, now }: ConsoleAppProps) {
           {problem.fellBack ? " — showing this service only." : " — the console is missing a service you configured."}
         </Banner>
       )}
-      <Console
-        key={service.name}
-        baseUrl={service.adminBaseUrl}
-        title={service.name}
-        fetcher={fetcher}
-        now={now}
-        services={services.length > 1 ? services.map((entry) => entry.name) : undefined}
-        activeService={service.name}
-        onSelectService={setActive}
-      />
-    </>
+
+      {section === TODAY ? (
+        <TriageHome
+          services={services.map((entry) => ({
+            name: entry.name,
+            client: clients.get(entry.name)!,
+            capabilities: discovered.get(entry.name) ?? null,
+          }))}
+          onOpen={open}
+          now={now}
+        />
+      ) : (
+        <ServicePanels client={client} capabilities={capabilities} section={section} now={now} />
+      )}
+    </AppShell>
   );
 }
diff --git a/ui/console/src/TriageHome.tsx b/ui/console/src/TriageHome.tsx
new file mode 100644
index 0000000..f08f54c
--- /dev/null
+++ b/ui/console/src/TriageHome.tsx
@@ -0,0 +1,104 @@
+import { useEffect, useState } from "react";
+import { Banner, StateBadge } from "@goldpath/kit";
+import type { AdminClient, ModuleName } from "./adminClient";
+import type { Capabilities } from "./sections";
+import { collectServiceTriage, orderTriage, TRIAGE_SCOPE, type TriageRow } from "./triage";
+
+export interface TriageService {
+  name: string;
+  client: AdminClient;
+  capabilities: Capabilities | null;
+}
+
+export interface TriageHomeProps {
+  services: TriageService[];
+  /** Opens the panel a row belongs to, on the service it belongs to. */
+  onOpen: (service: string, section: ModuleName) => void;
+  now?: Date;
+}
+
+/**
+ * The operator's first screen (console RFC D2): "is anything wrong", across every service
+ * in the registry. Fleet browsing is one click away, never the landing page — an operator
+ * opens a console to answer that question, not to browse.
+ *
+ * Every row is a deep link into the panel that owns it. Nothing here is a metric the API
+ * does not already publish: the triage reads the same take-bounded lists an operator would
+ * and counts what it was given, which is why the scope is printed rather than implied.
+ */
+export function TriageHome({ services, onOpen, now }: TriageHomeProps) {
+  const [rows, setRows] = useState<TriageRow[] | null>(null);
+  const [refreshToken, setRefreshToken] = useState(0);
+
+  const ready = services.filter((service) => service.capabilities !== null);
+  const readyKey = ready.map((service) => service.name).join("|");
+
+  useEffect(() => {
+    if (ready.length === 0) return;
+    let live = true;
+    setRows(null);
+    void Promise.all(
+      ready.map((service) => collectServiceTriage(service.name, service.client, service.capabilities!, now)),
+    ).then((collected) => live && setRows(orderTriage(collected.flat())));
+    return () => {
+      live = false;
+    };
+    // `readyKey` is the honest dependency: the set of services whose capabilities are known.
+  }, [readyKey, refreshToken]);
+
+  const waiting = services.length - ready.length;
+
+  return (
+    <div data-testid="triage-home" className="space-y-4">
+      <div className="flex flex-wrap items-baseline gap-3">
+        <h2 className="text-sm font-medium">Today</h2>
+        <span className="text-xs text-faint">across {services.length} service{services.length === 1 ? "" : "s"} · {TRIAGE_SCOPE}</span>
+        <button
+          className="ml-auto rounded-md border border-border bg-background px-3 py-1 text-sm hover:bg-accent"
+          onClick={() => setRefreshToken((token) => token + 1)}
+        >
+          refresh
+        </button>
+      </div>
+
+      {waiting > 0 && (
+        <p className="text-xs text-muted-foreground">
+          {waiting} service{waiting === 1 ? " is" : "s are"} still answering — this list will grow.
+        </p>
+      )}
+
+      {rows === null && ready.length > 0 && <p className="text-sm text-muted-foreground">Reading every surface…</p>}
+
+      {rows !== null && rows.length === 0 && (
+        // The quiet morning. Said plainly, and scoped — "nothing found HERE" is not the
+        // same claim as "nothing is wrong", and the console must not make the second one.
+        <Banner tone="success" live="status">
+          Nothing is waiting for you in {TRIAGE_SCOPE}.
+        </Banner>
+      )}
+
+      {rows !== null && rows.length > 0 && (
+        <ul className="space-y-2">
+          {rows.map((row, index) => (
+            <li
+              key={`${row.service}-${row.section}-${row.headline}-${index}`}
+              className="flex flex-wrap items-center gap-3 rounded-md border border-border/60 px-3 py-2"
+            >
+              {/* The word carries the meaning; the tone only reinforces it. Colour alone
+                  would leave the row unreadable to whoever cannot see the difference. */}
+              <StateBadge state={row.tone === "danger" ? "went wrong" : "will, unless"} tone={row.tone} />
+              <button
+                className="text-sm font-medium underline-offset-2 hover:underline"
+                onClick={() => onOpen(row.service, row.section)}
+              >
+                {row.headline}
+              </button>
+              <span className="text-xs text-muted-foreground">{row.detail}</span>
+              <span className="ml-auto text-xs text-faint">{row.service}</span>
+            </li>
+          ))}
+        </ul>
+      )}
+    </div>
+  );
+}
diff --git a/ui/console/src/index.ts b/ui/console/src/index.ts
index 70a8aac..3e15078 100644
--- a/ui/console/src/index.ts
+++ b/ui/console/src/index.ts
@@ -1,5 +1,7 @@
 export { AdminClient, AdminHttpError, MODULES, type AdminClientOptions, type ModuleName, type FleetInfo, type JobInfo, type RunSummary, type RunDetail, type AdminResult } from "./adminClient";
 export { RunConsole, type RunConsoleProps } from "./RunConsole";
-export { Console, type ConsoleProps } from "./Console";
 export { ConsoleApp, type ConsoleAppProps } from "./ConsoleApp";
+export { ServicePanels, SECTION_LABEL, composedSections, type Capabilities } from "./sections";
+export { TriageHome, type TriageHomeProps, type TriageService } from "./TriageHome";
+export { collectServiceTriage, orderTriage, TRIAGE_SCOPE, TRIAGE_TAKE, type TriageRow } from "./triage";
 export { loadRegistry, SAME_ORIGIN, type ServiceEntry } from "./registry";
diff --git a/ui/console/src/sections.test.tsx b/ui/console/src/sections.test.tsx
new file mode 100644
index 0000000..2ae1c62
--- /dev/null
+++ b/ui/console/src/sections.test.tsx
@@ -0,0 +1,57 @@
+import { render, screen } from "@testing-library/react";
+import { AdminClient, MODULES, type Capability, type ModuleName } from "./adminClient";
+import { composedSections, ServicePanels, type Capabilities } from "./sections";
+
+const capabilities = (over: Partial<Record<ModuleName, Capability>> = {}): Capabilities =>
+  Object.fromEntries(MODULES.map((module) => [module, over[module] ?? { kind: "absent" }])) as Capabilities;
+
+const client = new AdminClient({ fetcher: (async () => new Response("[]", { status: 200 })) as typeof fetch });
+
+describe("one service's panels", () => {
+  it("says it is still asking, rather than showing an empty screen that means nothing", () => {
+    render(<ServicePanels client={client} capabilities={null} section="jobs" />);
+
+    expect(screen.getByText(/Discovering capabilities…/)).toBeInTheDocument();
+  });
+
+  it("a service that composes NOTHING says so — the console is not broken, the app has no surface", () => {
+    render(<ServicePanels client={client} capabilities={capabilities()} section="jobs" />);
+
+    expect(screen.getByText(/No Goldpath admin surface answered here/)).toBeInTheDocument();
+  });
+
+  it("each composed section renders ITS panel — the switch has no wrong branch", async () => {
+    const answering = new AdminClient({
+      fetcher: (async () => new Response("[]", { status: 200, headers: { "content-type": "application/json" } })) as typeof fetch,
+    });
+
+    for (const [section, testId] of [
+      ["jobs", "run-console"],
+      ["bulk", "bulk-panel"],
+      ["campaign", "campaign-panel"],
+      ["notification", "notification-panel"],
+      ["archival", "archival-panel"],
+    ] as const) {
+      const view = render(
+        <ServicePanels client={answering} capabilities={capabilities({ [section]: { kind: "present" } })} section={section} />,
+      );
+      // Panels that load before they draw (bulk, the run console) need their first answer
+      // before the test id exists — which is itself the honest loading behaviour.
+      if (section === "jobs") {
+        expect(await view.findByText(/Discovering fleets|No runs recorded|Fleets/)).toBeTruthy();
+      } else {
+        expect(await view.findByTestId(testId)).toBeTruthy();
+      }
+
+      view.unmount();
+    }
+  });
+
+  it("composedSections keeps the standard order, whatever order the probes answered in", () => {
+    expect(composedSections(capabilities({ campaign: { kind: "present" }, jobs: { kind: "forbidden" } }))).toEqual([
+      "jobs",
+      "campaign",
+    ]);
+    expect(composedSections(null)).toEqual([]);
+  });
+});
diff --git a/ui/console/src/sections.tsx b/ui/console/src/sections.tsx
new file mode 100644
index 0000000..3eab1a1
--- /dev/null
+++ b/ui/console/src/sections.tsx
@@ -0,0 +1,78 @@
+import { Banner } from "@goldpath/kit";
+import { MODULES, type AdminClient, type Capability, type ModuleName } from "./adminClient";
+import { RunConsole } from "./RunConsole";
+import { BulkPanel } from "./BulkPanel";
+import { CampaignPanel } from "./CampaignPanel";
+import { NotificationPanel } from "./NotificationPanel";
+import { ArchivalPanel } from "./ArchivalPanel";
+
+/** What the rail calls each module — the operator's word, not the package's. */
+export const SECTION_LABEL: Record<ModuleName, string> = {
+  jobs: "Runs",
+  archival: "Archival",
+  bulk: "Bulk intake",
+  notification: "Notifications",
+  campaign: "Campaigns",
+};
+
+export type Capabilities = Record<ModuleName, Capability>;
+
+/** The modules this service actually composes, in the standard order. */
+export function composedSections(capabilities: Capabilities | null): ModuleName[] {
+  return capabilities ? MODULES.filter((module) => capabilities[module].kind !== "absent") : [];
+}
+
+export interface ServicePanelsProps {
+  client: AdminClient;
+  capabilities: Capabilities | null;
+  section: ModuleName;
+  now?: Date;
+}
+
+/**
+ * One service's panels, without a shell around them — the shell belongs to the console as
+ * a whole, because the operator's first screen is the estate, not any single service.
+ *
+ * A module the app never composed is absent (no panel, no dead link). One that is present
+ * but REFUSING — no ops role, or no tenant to scope the call to — says exactly that, in
+ * the server's words.
+ */
+export function ServicePanels({ client, capabilities, section, now }: ServicePanelsProps) {
+  if (capabilities === null) {
+    return <p className="text-sm text-muted-foreground">Discovering capabilities…</p>;
+  }
+
+  if (composedSections(capabilities).length === 0) {
+    return (
+      <p className="text-sm text-muted-foreground">
+        No Goldpath admin surface answered here — this app composes none, or the service is unreachable.
+      </p>
+    );
+  }
+
+  const capability = capabilities[section];
+
+  return (
+    <>
+      {capability.kind === "forbidden" && (
+        <Banner tone="warning">
+          {SECTION_LABEL[section]} exists on this service, but your account lacks the ops role for it.
+          {capability.message ? ` The service said: “${capability.message}”` : ""}
+        </Banner>
+      )}
+
+      {capability.kind === "refused" && (
+        <Banner tone="warning">
+          {SECTION_LABEL[section]} is composed here but refused this request.
+          {capability.message ? ` The service said: “${capability.message}”` : ""}
+        </Banner>
+      )}
+
+      {capability.kind === "present" && section === "jobs" && <RunConsole client={client} now={now} />}
+      {capability.kind === "present" && section === "bulk" && <BulkPanel client={client} />}
+      {capability.kind === "present" && section === "campaign" && <CampaignPanel client={client} />}
+      {capability.kind === "present" && section === "notification" && <NotificationPanel client={client} />}
+      {capability.kind === "present" && section === "archival" && <ArchivalPanel client={client} />}
+    </>
+  );
+}
diff --git a/ui/console/src/triage.test.ts b/ui/console/src/triage.test.ts
new file mode 100644
index 0000000..12331f0
--- /dev/null
+++ b/ui/console/src/triage.test.ts
@@ -0,0 +1,312 @@
+import { AdminClient, MODULES, type Capability, type ModuleName } from "./adminClient";
+import type { Capabilities } from "./sections";
+import { collectServiceTriage, orderTriage, type TriageRow } from "./triage";
+
+const NOW = new Date("2026-07-27T12:00:00Z");
+
+/** Every module present unless said otherwise — the triage reads what a service composes. */
+function capabilities(over: Partial<Record<ModuleName, Capability>> = {}): Capabilities {
+  return Object.fromEntries(
+    MODULES.map((module) => [module, over[module] ?? { kind: "present" }]),
+  ) as Capabilities;
+}
+
+const run = (over: Record<string, unknown> = {}) => ({
+  id: "r1",
+  jobName: "eod-reconciliation",
+  status: "Completed",
+  startedAt: "2026-07-27T11:00:00Z",
+  totalChunks: 4,
+  completedChunks: 4,
+  failedChunks: 0,
+  itemFailures: 0,
+  ...over,
+});
+
+function client(routes: Record<string, unknown>, fail?: (url: string) => boolean) {
+  const fetcher = (async (input: RequestInfo | URL) => {
+    const url = String(input);
+    if (fail?.(url)) return new Response("", { status: 503 });
+    // The DEEPEST match wins: `/goldpath/admin/jobs/fleets/it/runs?take=50` contains both
+    // "/jobs/fleets" and "/runs?", and answering it with the fleet list would quietly make
+    // every run assertion vacuous.
+    const match = Object.keys(routes)
+      .filter((key) => url.includes(key))
+      .sort((left, right) => url.indexOf(right) - url.indexOf(left))[0];
+    return new Response(JSON.stringify(match ? routes[match] : []), {
+      status: 200,
+      headers: { "content-type": "application/json" },
+    });
+  }) as typeof fetch;
+  return new AdminClient({ fetcher });
+}
+
+const headlines = (rows: TriageRow[]) => rows.map((row) => row.headline);
+
+describe("triage — what is wrong, read from the contract's own lists", () => {
+  it("a quiet estate produces NO rows: silence is the answer, not an empty metric", async () => {
+    const api = client({ "/jobs/fleets": [{ schedulerName: "it", jobCount: 1, nodes: [] }], "/runs?": [run()] });
+
+    expect(await collectServiceTriage("payments", api, capabilities(), NOW)).toEqual([]);
+  });
+
+  it("failed runs are DANGER and name the jobs", async () => {
+    const api = client({
+      "/jobs/fleets": [{ schedulerName: "it", jobCount: 2, nodes: [] }],
+      "/runs?": [run({ status: "Failed", jobName: "settlement" }), run()],
+    });
+
+    const rows = await collectServiceTriage("payments", api, capabilities(), NOW);
+
+    expect(rows[0]).toMatchObject({ tone: "danger", section: "jobs", service: "payments" });
+    expect(rows[0].headline).toBe("1 failed run in it");
+    expect(rows[0].detail).toBe("settlement");
+  });
+
+  it("a run still going past its deadline is DANGER; one merely predicted to overrun is a warning", async () => {
+    const api = client({
+      "/jobs/fleets": [{ schedulerName: "it", jobCount: 2, nodes: [] }],
+      "/runs?": [
+        run({ id: "late", status: "Running", deadlineAt: "2026-07-27T11:30:00Z", jobName: "nightly" }),
+        run({ id: "soon", status: "Running", deadlineAt: "2026-07-27T13:00:00Z", predictedFinishAt: "2026-07-27T13:30:00Z", jobName: "sweep" }),
+      ],
+    });
+
+    const rows = await collectServiceTriage("payments", api, capabilities(), NOW);
+
+    expect(rows.find((row) => row.tone === "danger")?.headline).toBe("1 run past the deadline in it");
+    expect(rows.find((row) => row.tone === "warning")?.headline).toBe("1 run predicted to overrun in it");
+  });
+
+  it("the repair queue is counted across the runs it was given", async () => {
+    const api = client({
+      "/jobs/fleets": [{ schedulerName: "it", jobCount: 1, nodes: [] }],
+      "/runs?": [run({ itemFailures: 3 }), run({ id: "r2", itemFailures: 4 })],
+    });
+
+    const rows = await collectServiceTriage("payments", api, capabilities(), NOW);
+
+    expect(headlines(rows)).toContain("7 items waiting in the repair queue of it");
+  });
+
+  it("a four-eyes gate holding batches is a warning, with how long the oldest has waited", async () => {
+    const api = client({
+      "/bulk/definitions": [{ name: "payouts", batchesByState: {}, awaitingApproval: 2, oldestAwaitingApprovalSeconds: 7200 }],
+    });
+
+    const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW);
+
+    expect(rows[0].headline).toBe("2 batches awaiting approval in payouts");
+    expect(rows[0].detail).toBe("the oldest has waited 2h");
+  });
+
+  it("a paused campaign is reported — someone stopped it and nobody resumed it", async () => {
+    const api = client({
+      "/campaign/?": [
+        { id: "c1", name: "june-welcome", state: "Paused", failedCount: 0, succeededCount: 10, remaining: 90, tps: 2, releasedThrough: 10, enumeratedThrough: 100, enumerationComplete: true, inFlight: 0, releasedToday: 10, maxInFlight: 5, timeZoneId: "UTC", windowOpenNow: true, createdAt: "", createdBy: "" },
+      ],
+    });
+
+    const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW);
+
+    expect(headlines(rows)).toContain("june-welcome is paused");
+    expect(rows[0].detail).toContain("90 items");
+  });
+
+  it("failed notifications are DANGER; a queue that has not moved in a quarter of an hour is a warning", async () => {
+    const api = client({
+      "/notification/templates": [
+        { key: "welcome", hash: "h", byState: { Failed: 2, Sent: 8 }, oldestRequestedSeconds: 1800 },
+      ],
+    });
+
+    const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW);
+
+    expect(rows[0]).toMatchObject({ tone: "danger", headline: "2 notifications failed on welcome" });
+    expect(headlines(rows)).toContain("welcome has a request waiting 30m");
+  });
+
+  it("an archive that has not caught up is a warning", async () => {
+    const api = client({
+      "/archival/definitions": [{ name: "policies", entries: 10, dueBacklog: 4, activeHolds: 0, chainHead: 10, purgedThrough: 0 }],
+    });
+
+    const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW);
+
+    expect(rows[0].headline).toBe("4 aggregates due to archive in policies");
+  });
+
+  it("an ABSENT capability contributes nothing; a REFUSING one contributes the blind spot, never a guessed number", async () => {
+    // The bulk surface would answer "9 awaiting approval" if we asked — but the capability
+    // probe was refused, so triage must not read it and must not pretend it did.
+    const api = client({ "/bulk/definitions": [{ name: "payouts", batchesByState: {}, awaitingApproval: 9 }] });
+
+    const rows = await collectServiceTriage(
+      "payments",
+      api,
+      capabilities({
+        jobs: { kind: "absent" },
+        campaign: { kind: "absent" },
+        notification: { kind: "absent" },
+        archival: { kind: "absent" },
+        bulk: { kind: "refused", message: "no ambient tenant" },
+      }),
+      NOW,
+    );
+
+    expect(rows).toHaveLength(1);
+    expect(rows[0].headline).toBe("1 surface on payments cannot be read");
+    expect(rows.some((row) => row.headline.includes("9"))).toBe(false);
+  });
+
+  it("a surface that DIES mid-read becomes a row of its own — triage never drops a service quietly", async () => {
+    const api = client({ "/jobs/fleets": [{ schedulerName: "it", jobCount: 1, nodes: [] }] }, (url) => url.includes("/runs?"));
+
+    const rows = await collectServiceTriage("payments", api, capabilities({ bulk: { kind: "absent" }, campaign: { kind: "absent" }, notification: { kind: "absent" }, archival: { kind: "absent" } }), NOW);
+
+    expect(rows).toEqual([
+      {
+        service: "payments",
+        section: "jobs",
+        tone: "danger",
+        headline: "the run surface could not be read",
+        detail: "the surface answered, then stopped — triage cannot speak for it",
+      },
+    ]);
+  });
+
+  it("orders danger before warning, then by service — the worst row is always first", () => {
+    const row = (tone: "danger" | "warning", service: string): TriageRow => ({
+      service,
+      section: "jobs",
+      tone,
+      headline: `${tone} on ${service}`,
+      detail: "",
+    });
+
+    expect(headlines(orderTriage([row("warning", "b"), row("danger", "z"), row("warning", "a"), row("danger", "a")]))).toEqual([
+      "danger on a",
+      "danger on z",
+      "warning on a",
+      "warning on b",
+    ]);
+  });
+
+  it("failed campaign items are reported with what did land", async () => {
+    const api = client({
+      "/campaign/?": [
+        { id: "c1", name: "june-welcome", state: "Running", failedCount: 1, succeededCount: 40, remaining: 59, tps: 2, releasedThrough: 41, enumeratedThrough: 100, enumerationComplete: true, inFlight: 1, releasedToday: 41, maxInFlight: 5, timeZoneId: "UTC", windowOpenNow: true, createdAt: "", createdBy: "" },
+      ],
+    });
+
+    const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW);
+
+    // Singular, because one item is one item — the count is the operator's first read.
+    expect(rows[0].headline).toBe("1 failed item in june-welcome");
+    expect(rows[0].detail).toBe("40 succeeded · 59 remaining");
+  });
+
+  it("every surface reports its OWN death, not just the run surface", async () => {
+    for (const [surface, route, what] of [
+      ["bulk", "/bulk/definitions", "the intake surface"],
+      ["campaign", "/campaign/?", "the campaign surface"],
+      ["notification", "/notification/templates", "the notification surface"],
+      ["archival", "/archival/definitions", "the archival surface"],
+    ] as const) {
+      const only = Object.fromEntries(
+        MODULES.map((module) => [module, module === surface ? { kind: "present" } : { kind: "absent" }]),
+      ) as Capabilities;
+      const api = client({}, (url) => url.includes(route.replace("?", "")));
+
+      const rows = await collectServiceTriage("payments", api, only, NOW);
+
+      expect(rows).toEqual([
+        {
+          service: "payments",
+          section: surface,
+          tone: "danger",
+          headline: `${what} could not be read`,
+          detail: "the surface answered, then stopped — triage cannot speak for it",
+        },
+      ]);
+    }
+  });
+
+  it("plurals follow the count, in every row the operator reads", async () => {
+    const api = client({
+      "/jobs/fleets": [{ schedulerName: "it", jobCount: 3, nodes: [] }],
+      "/runs?": [
+        run({ status: "Failed" }),
+        run({ id: "r2", status: "Failed", jobName: "settlement" }),
+        run({ id: "r3", itemFailures: 1 }),
+      ],
+    });
+
+    const rows = await collectServiceTriage("payments", api, capabilities({ bulk: { kind: "absent" }, campaign: { kind: "absent" }, notification: { kind: "absent" }, archival: { kind: "absent" } }), NOW);
+
+    expect(headlines(rows)).toContain("2 failed runs in it");
+    expect(headlines(rows)).toContain("1 item waiting in the repair queue of it");
+  });
+
+  it("a gate with no age still says a gate is holding — an absent number is not a zero", async () => {
+    const api = client({
+      "/bulk/definitions": [{ name: "payouts", batchesByState: {}, awaitingApproval: 1, oldestAwaitingApprovalSeconds: null }],
+    });
+
+    const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW);
+
+    expect(rows[0].headline).toBe("1 batch awaiting approval in payouts");
+    expect(rows[0].detail).toBe("a four-eyes gate is holding them");
+  });
+
+  it("one failed notification reads in the singular, and a young queue says nothing at all", async () => {
+    const api = client({
+      "/notification/templates": [{ key: "welcome", hash: "h", byState: { Failed: 1 }, oldestRequestedSeconds: 60 }],
+    });
+
+    const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW);
+
+    expect(headlines(rows)).toEqual(["1 notification failed on welcome"]);
+  });
+
+  it("one aggregate due to archive reads in the singular", async () => {
+    const api = client({
+      "/archival/definitions": [{ name: "policies", entries: 1, dueBacklog: 1, activeHolds: 0, chainHead: 1, purgedThrough: 0 }],
+    });
+
+    const rows = await collectServiceTriage("payments", api, capabilities({ jobs: { kind: "absent" } }), NOW);
+
+    expect(rows[0].headline).toBe("1 aggregate due to archive in policies");
+  });
+
+  it("a service the console CANNOT READ is one row, not five — and it names the reason", async () => {
+    const api = client({});
+    const blind = Object.fromEntries(
+      MODULES.map((module) => [
+        module,
+        module === "jobs"
+          ? { kind: "forbidden", message: "the 'goldpath-ops' role is required" }
+          : { kind: "forbidden" },
+      ]),
+    ) as Capabilities;
+
+    const rows = await collectServiceTriage("auth-floored", api, blind, NOW);
+
+    expect(rows).toHaveLength(1);
+    expect(rows[0]).toMatchObject({ tone: "warning", service: "auth-floored", section: "jobs" });
+    expect(rows[0].headline).toBe("5 surfaces on auth-floored cannot be read");
+    expect(rows[0].detail).toContain("the 'goldpath-ops' role is required");
+  });
+
+  it("one unreadable surface reads in the singular, and without a message says what it can", async () => {
+    const rows = await collectServiceTriage(
+      "payments",
+      client({}),
+      capabilities({ jobs: { kind: "refused" }, bulk: { kind: "absent" }, campaign: { kind: "absent" }, notification: { kind: "absent" }, archival: { kind: "absent" } }),
+      NOW,
+    );
+
+    expect(rows[0].headline).toBe("1 surface on payments cannot be read");
+    expect(rows[0].detail).toBe("this operator may not see them, or the request cannot be scoped");
+  });
+});
diff --git a/ui/console/src/triage.ts b/ui/console/src/triage.ts
new file mode 100644
index 0000000..8eaacf6
--- /dev/null
+++ b/ui/console/src/triage.ts
@@ -0,0 +1,234 @@
+import { deadlineVerdict, humanizeSeconds } from "@goldpath/kit";
+import { MODULES, type AdminClient, type ModuleName } from "./adminClient";
+import type { Capabilities } from "./sections";
+
+/**
+ * One thing worth an operator's attention. `tone` is the semantic ramp, not a priority
+ * number: "danger" is something that already went wrong, "warning" is something that will
+ * unless someone acts.
+ */
+export interface TriageRow {
+  service: string;
+  section: ModuleName;
+  tone: "danger" | "warning";
+  headline: string;
+  detail: string;
+}
+
+/** How much of each surface the triage reads. The contract is take-bounded everywhere. */
+export const TRIAGE_TAKE = 50;
+
+/**
+ * The caveat the screen must SAY, because the numbers below are honest only within it:
+ * the admin contract has no aggregate endpoint, so triage reads the same take-bounded
+ * lists an operator would, and counts what it was given.
+ */
+export const TRIAGE_SCOPE = `the most recent ${TRIAGE_TAKE} rows of each surface`;
+
+/**
+ * Collects what is wrong on ONE service. Every number comes from the frozen contract's
+ * own lists — the console invents no aggregate the API does not expose, and a capability
+ * that is absent, forbidden or refusing contributes nothing rather than a guess.
+ *
+ * A surface that FAILS to answer is itself a row: an operator whose triage silently drops
+ * a service is worse off than one who sees nothing at all.
+ */
+export async function collectServiceTriage(
+  service: string,
+  client: AdminClient,
+  capabilities: Capabilities,
+  now: Date = new Date(),
+): Promise<TriageRow[]> {
+  const rows: TriageRow[] = [];
+  const present = (module: ModuleName) => capabilities[module].kind === "present";
+
+  // A surface the console cannot READ is triage's own blind spot, and blindness during an
+  // incident is exactly what an operator must be told about. Grouped per service: five
+  // identical "forbidden" rows would bury the estate's real problems under our own.
+  const unreadable = MODULES.filter((module) => {
+    const kind = capabilities[module].kind;
+    return kind === "forbidden" || kind === "refused";
+  });
+
+  if (unreadable.length > 0) {
+    const said = capabilities[unreadable[0]].kind === "forbidden" || capabilities[unreadable[0]].kind === "refused"
+      ? (capabilities[unreadable[0]] as { message?: string }).message
+      : undefined;
+    rows.push({
+      service,
+      section: unreadable[0],
+      tone: "warning",
+      headline: `${unreadable.length} surface${unreadable.length === 1 ? "" : "s"} on ${service} cannot be read`,
+      detail: said ? `the service said: “${said}”` : "this operator may not see them, or the request cannot be scoped",
+    });
+  }
+
+  const unreachable = (section: ModuleName, what: string) =>
+    rows.push({
+      service,
+      section,
+      tone: "danger",
+      headline: `${what} could not be read`,
+      detail: "the surface answered, then stopped — triage cannot speak for it",
+    });
+
+  if (present("jobs")) {
+    try {
+      const fleets = await client.fleets();
+      for (const fleet of fleets) {
+        const runs = await client.runs(fleet.schedulerName, { take: TRIAGE_TAKE });
+        const failed = runs.filter((run) => run.status === "Failed");
+        const overrun = runs.filter((run) => deadlineVerdict(run, now) === "overrun" && run.status !== "Failed");
+        const predicted = runs.filter((run) => deadlineVerdict(run, now) === "overrun-predicted");
+        const repair = runs.reduce((sum, run) => sum + run.itemFailures, 0);
+
+        if (failed.length > 0) {
+          rows.push({
+            service,
+            section: "jobs",
+            tone: "danger",
+            headline: `${failed.length} failed run${failed.length === 1 ? "" : "s"} in ${fleet.schedulerName}`,
+            detail: failed.map((run) => run.jobName).slice(0, 3).join(", "),
+          });
+        }
+
+        if (overrun.length > 0) {
+          rows.push({
+            service,
+            section: "jobs",
+            tone: "danger",
+            headline: `${overrun.length} run${overrun.length === 1 ? "" : "s"} past the deadline in ${fleet.schedulerName}`,
+            detail: overrun.map((run) => run.jobName).slice(0, 3).join(", "),
+          });
+        }
+
+        if (predicted.length > 0) {
+          rows.push({
+            service,
+            section: "jobs",
+            tone: "warning",
+            headline: `${predicted.length} run${predicted.length === 1 ? "" : "s"} predicted to overrun in ${fleet.schedulerName}`,
+            detail: predicted.map((run) => run.jobName).slice(0, 3).join(", "),
+          });
+        }
+
+        if (repair > 0) {
+          rows.push({
+            service,
+            section: "jobs",
+            tone: "warning",
+            headline: `${repair} item${repair === 1 ? "" : "s"} waiting in the repair queue of ${fleet.schedulerName}`,
+            detail: "replay them from the run console once the cause is fixed",
+          });
+        }
+      }
+    } catch {
+      unreachable("jobs", "the run surface");
+    }
+  }
+
+  if (present("bulk")) {
+    try {
+      for (const definition of await client.bulkDefinitions()) {
+        if (definition.awaitingApproval > 0) {
+          const age = definition.oldestAwaitingApprovalSeconds;
+          rows.push({
+            service,
+            section: "bulk",
+            tone: "warning",
+            headline: `${definition.awaitingApproval} batch${definition.awaitingApproval === 1 ? "" : "es"} awaiting approval in ${definition.name}`,
+            detail: age ? `the oldest has waited ${humanizeSeconds(age)}` : "a four-eyes gate is holding them",
+          });
+        }
+      }
+    } catch {
+      unreachable("bulk", "the intake surface");
+    }
+  }
+
+  if (present("campaign")) {
+    try {
+      for (const campaign of await client.campaigns({ take: TRIAGE_TAKE })) {
+        if (campaign.failedCount > 0) {
+          rows.push({
+            service,
+            section: "campaign",
+            tone: "warning",
+            headline: `${campaign.failedCount} failed item${campaign.failedCount === 1 ? "" : "s"} in ${campaign.name}`,
+            detail: `${campaign.succeededCount} succeeded · ${campaign.remaining} remaining`,
+          });
+        }
+
+        if (campaign.state === "Paused") {
+          rows.push({
+            service,
+            section: "campaign",
+            tone: "warning",
+            headline: `${campaign.name} is paused`,
+            detail: `${campaign.remaining} items are waiting for someone to resume it`,
+          });
+        }
+      }
+    } catch {
+      unreachable("campaign", "the campaign surface");
+    }
+  }
+
+  if (present("notification")) {
+    try {
+      for (const template of await client.notificationTemplates()) {
+        const failed = template.byState.Failed ?? 0;
+        if (failed > 0) {
+          rows.push({
+            service,
+            section: "notification",
+            tone: "danger",
+            headline: `${failed} notification${failed === 1 ? "" : "s"} failed on ${template.key}`,
+            detail: "the transport refused them — the evidence rows carry its words",
+          });
+        }
+
+        const waiting = template.oldestRequestedSeconds ?? 0;
+        if (waiting > 900) {
+          rows.push({
+            service,
+            section: "notification",
+            tone: "warning",
+            headline: `${template.key} has a request waiting ${humanizeSeconds(waiting)}`,
+            detail: "the send job may not be running",
+          });
+        }
+      }
+    } catch {
+      unreachable("notification", "the notification surface");
+    }
+  }
+
+  if (present("archival")) {
+    try {
+      for (const archive of await client.archiveDefinitions()) {
+        if (archive.dueBacklog > 0) {
+          rows.push({
+            service,
+            section: "archival",
+            tone: "warning",
+            headline: `${archive.dueBacklog} aggregate${archive.dueBacklog === 1 ? "" : "s"} due to archive in ${archive.name}`,
+            detail: "the archive run has not caught up with them yet",
+          });
+        }
+      }
+    } catch {
+      unreachable("archival", "the archival surface");
+    }
+  }
+
+  return rows;
+}
+
+/** Danger before warning, then by service, so the worst row is always the first one. */
+export function orderTriage(rows: TriageRow[]): TriageRow[] {
+  return [...rows].sort((left, right) => {
+    if (left.tone !== right.tone) return left.tone === "danger" ? -1 : 1;
+    return left.service.localeCompare(right.service);
+  });
+}

From 138b1d46b950ce0cf351cc7415be81a7f8182598 Mon Sep 17 00:00:00 2001
From: Omer Celik <omer@omercelik.dev>
Date: Mon, 27 Jul 2026 15:18:33 +0300
Subject: [PATCH 2/2] fix(console): a blind spot outranks the list it qualifies
 (review R1/R6)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

R1 caught my own sentence contradicting my own code: the RFC says blindness during an
incident is the most important thing an operator can be told, and the row was toned
"warning" — sorting below every ordinary failure. A row that says "this list is
incomplete" belongs ABOVE the list, so it is now marked `blind` and ranked first, ahead
even of danger; its chip reads "cannot see" rather than borrowing a failure's words.

R6: dropped a re-check of the condition the filter had just established.

18/18.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 ui/console/src/TriageHome.tsx |  5 ++++-
 ui/console/src/triage.test.ts | 26 ++++++++++++++++----------
 ui/console/src/triage.ts      | 26 +++++++++++++++++++-------
 3 files changed, 39 insertions(+), 18 deletions(-)

diff --git a/ui/console/src/TriageHome.tsx b/ui/console/src/TriageHome.tsx
index f08f54c..3e9f445 100644
--- a/ui/console/src/TriageHome.tsx
+++ b/ui/console/src/TriageHome.tsx
@@ -86,7 +86,10 @@ export function TriageHome({ services, onOpen, now }: TriageHomeProps) {
             >
               {/* The word carries the meaning; the tone only reinforces it. Colour alone
                   would leave the row unreadable to whoever cannot see the difference. */}
-              <StateBadge state={row.tone === "danger" ? "went wrong" : "will, unless"} tone={row.tone} />
+              <StateBadge
+                state={row.blind ? "cannot see" : row.tone === "danger" ? "went wrong" : "will, unless"}
+                tone={row.tone}
+              />
               <button
                 className="text-sm font-medium underline-offset-2 hover:underline"
                 onClick={() => onOpen(row.service, row.section)}
diff --git a/ui/console/src/triage.test.ts b/ui/console/src/triage.test.ts
index 12331f0..0dcf327 100644
--- a/ui/console/src/triage.test.ts
+++ b/ui/console/src/triage.test.ts
@@ -175,21 +175,27 @@ describe("triage — what is wrong, read from the contract's own lists", () => {
     ]);
   });
 
-  it("orders danger before warning, then by service — the worst row is always first", () => {
-    const row = (tone: "danger" | "warning", service: string): TriageRow => ({
+  it("orders BLIND first, then danger, then warning — a partial picture must say so above the picture", () => {
+    const row = (tone: "danger" | "warning", service: string, blind?: true): TriageRow => ({
       service,
       section: "jobs",
       tone,
-      headline: `${tone} on ${service}`,
+      ...(blind ? { blind } : {}),
+      headline: `${blind ? "blind" : tone} on ${service}`,
       detail: "",
     });
 
-    expect(headlines(orderTriage([row("warning", "b"), row("danger", "z"), row("warning", "a"), row("danger", "a")]))).toEqual([
-      "danger on a",
-      "danger on z",
-      "warning on a",
-      "warning on b",
-    ]);
+    expect(
+      headlines(
+        orderTriage([
+          row("warning", "b"),
+          row("danger", "z"),
+          row("danger", "m", true),
+          row("warning", "a"),
+          row("danger", "a"),
+        ]),
+      ),
+    ).toEqual(["blind on m", "danger on a", "danger on z", "warning on a", "warning on b"]);
   });
 
   it("failed campaign items are reported with what did land", async () => {
@@ -293,7 +299,7 @@ describe("triage — what is wrong, read from the contract's own lists", () => {
     const rows = await collectServiceTriage("auth-floored", api, blind, NOW);
 
     expect(rows).toHaveLength(1);
-    expect(rows[0]).toMatchObject({ tone: "warning", service: "auth-floored", section: "jobs" });
+    expect(rows[0]).toMatchObject({ tone: "danger", blind: true, service: "auth-floored", section: "jobs" });
     expect(rows[0].headline).toBe("5 surfaces on auth-floored cannot be read");
     expect(rows[0].detail).toContain("the 'goldpath-ops' role is required");
   });
diff --git a/ui/console/src/triage.ts b/ui/console/src/triage.ts
index 8eaacf6..0837c26 100644
--- a/ui/console/src/triage.ts
+++ b/ui/console/src/triage.ts
@@ -11,6 +11,13 @@ export interface TriageRow {
   service: string;
   section: ModuleName;
   tone: "danger" | "warning";
+  /**
+   * A BLIND row is not another problem in the list — it says the list itself is
+   * incomplete, which qualifies everything else on the screen. That is why it sorts ahead
+   * even of failures (review R1: the tone alone put it below them, contradicting the very
+   * claim the screen makes).
+   */
+  blind?: true;
   headline: string;
   detail: string;
 }
@@ -51,13 +58,13 @@ export async function collectServiceTriage(
   });
 
   if (unreadable.length > 0) {
-    const said = capabilities[unreadable[0]].kind === "forbidden" || capabilities[unreadable[0]].kind === "refused"
-      ? (capabilities[unreadable[0]] as { message?: string }).message
-      : undefined;
+    // The filter above already established the kind; the message is all that is left to read.
+    const said = (capabilities[unreadable[0]] as { message?: string }).message;
     rows.push({
       service,
       section: unreadable[0],
-      tone: "warning",
+      tone: "danger",
+      blind: true,
       headline: `${unreadable.length} surface${unreadable.length === 1 ? "" : "s"} on ${service} cannot be read`,
       detail: said ? `the service said: “${said}”` : "this operator may not see them, or the request cannot be scoped",
     });
@@ -225,10 +232,15 @@ export async function collectServiceTriage(
   return rows;
 }
 
-/** Danger before warning, then by service, so the worst row is always the first one. */
+/**
+ * Blind spots first, then danger, then warning, then by service. A row that says "this
+ * list is incomplete" belongs above every row IN the list — otherwise the screen quietly
+ * invites an operator to trust a picture it knows is partial.
+ */
 export function orderTriage(rows: TriageRow[]): TriageRow[] {
+  const rank = (row: TriageRow) => (row.blind ? 0 : row.tone === "danger" ? 1 : 2);
   return [...rows].sort((left, right) => {
-    if (left.tone !== right.tone) return left.tone === "danger" ? -1 : 1;
-    return left.service.localeCompare(right.service);
+    const difference = rank(left) - rank(right);
+    return difference !== 0 ? difference : left.service.localeCompare(right.service);
   });
 }
