From 1d895808fd8955bc5fb1dbbfb5467d45e1a967f2 Mon Sep 17 00:00:00 2001
From: Omer Celik <omer@omercelik.dev>
Date: Mon, 27 Jul 2026 16:47:41 +0300
Subject: [PATCH 1/2] =?UTF-8?q?feat(console):=20"we=20could=20not=20ask"?=
 =?UTF-8?q?=20is=20not=20"they=20do=20not=20have=20it"=20=E2=80=94=20and?=
 =?UTF-8?q?=20the=20auth=20story,=20settled?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

U4 slice 4.

The bug this slice starts with: a probe that THREW was filed as `absent`. A service that is
down, or one whose CORS refuses this console's origin, therefore appeared as an app that
composes no Goldpath module — a confident, wrong sentence, and precisely the one an
operator would act on during an outage. Unreachable is now its own answer: named on the
service's own screen, named per section, and reported on TODAY as a blind spot (which
already outranks the whole list).

The auth story, written down in the RFC rather than implied:
1. SAME ORIGIN is the shape we ship — the console is served by the app it drives, behind
   the same ops floor as the admin surfaces, so an unauthenticated operator is challenged
   for the console's own document and never reaches a screen. Nothing to configure, and no
   way to ship an unauthenticated console by accident.
2. CROSS-SERVICE needs each service to share the console's auth boundary or to allow its
   origin in CORS with credentials. There is no third option that does not involve a token.
3. A token the console HOLDS is deliberately not built: a browser-side OIDC client puts an
   identity library in the dist and a per-adopter authority in config, which is a different
   product decision. Recorded as open-threads T10 with the trigger that would change it.

Proof: the console mapped on the auth-floored host answers 401 for its own page — the
property that matters most here is the one an adopter cannot get wrong. 22/22 green;
console unit 139.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 docs/rfc/goldpath-console.md       | 23 ++++++++++++++++++++---
 docs/strategy/open-threads.md      |  1 +
 ui/console/e2e/served.spec.ts      | 10 ++++++++++
 ui/console/src/ConsoleApp.test.tsx | 15 +++++++++++++++
 ui/console/src/ConsoleApp.tsx      |  6 ++++--
 ui/console/src/adminClient.test.ts |  8 ++++++--
 ui/console/src/adminClient.ts      | 13 +++++++++++--
 ui/console/src/sections.test.tsx   | 15 +++++++++++++--
 ui/console/src/sections.tsx        | 23 ++++++++++++++++++++++-
 ui/console/src/triage.test.ts      | 11 +++++++++++
 ui/console/src/triage.ts           | 12 +++++++++---
 11 files changed, 122 insertions(+), 15 deletions(-)

diff --git a/docs/rfc/goldpath-console.md b/docs/rfc/goldpath-console.md
index bea2c64..9f0f804 100644
--- a/docs/rfc/goldpath-console.md
+++ b/docs/rfc/goldpath-console.md
@@ -47,9 +47,26 @@ custom-develop ON, with the same kit, the same way they add features to the back
   appear under another's name. A registry that fails to load is ANNOUNCED, because an
   operator who configured four services and silently sees one is looking at the wrong
   console.
-- **Auth**: the console signs in ONCE against the adopter's IdP and carries the token
-  to every service — all surfaces demand `goldpath-ops` (H2); the console is just a
-  well-dressed client of that floor. The login-gate primitive exists in the kit.
+- **Auth** (settled in U4, 2026-07-27 — and deliberately NOT an IdP client of our own):
+
+  1. **Same origin — the shape we ship.** The console is served by the app it drives
+     (`MapGoldpathConsole`), behind the SAME ops floor as the admin surfaces. The browser
+     is challenged for the console's own document, so an unauthenticated operator never
+     reaches a screen at all; every subsequent call rides whatever the app's floor already
+     established (cookie, or a gateway's header). Nothing to configure, and no way to ship
+     an unauthenticated console by accident — proven in the smoke: the console mapped on
+     the auth-floored host answers 401 for its own page.
+  2. **Cross-service.** The console sends its calls with credentials, so each additional
+     service must (a) sit under the same auth as the console's origin, or (b) allow that
+     origin explicitly in CORS *with* credentials. There is no third option that does not
+     involve a token the console would have to hold.
+  3. **A token the console holds is NOT built.** A browser-side OIDC client would put an
+     identity library in the dist and a per-adopter authority/clientId in config, which is
+     a different product decision from "the console is a client of the app's floor". Until
+     an adopter needs it, the honest answer is (1) and (2) — and the console says so when
+     a service refuses: a call blocked by CORS is reported as UNREACHABLE, never as an
+     absent module, because "we could not ask" and "they do not have it" are different
+     sentences.
 - **External products**: Mockifyr (mocks) and other products appear as configured LINK
   tiles — their UI is theirs.
 
diff --git a/docs/strategy/open-threads.md b/docs/strategy/open-threads.md
index 08d5abf..2adac7b 100644
--- a/docs/strategy/open-threads.md
+++ b/docs/strategy/open-threads.md
@@ -18,6 +18,7 @@ pending forever.
 | T5 | **LLM-half skill evals** — running the skills themselves per fixture | Deterministic acceptance runs nightly; running the skills needs agent-in-CI | agent-in-CI exists | a skill that fails its evals cannot be released ([ai-sdlc-status.md](ai-sdlc-status.md) §2) |
 | T6 | **CLI-as-MCP** and **plugin packaging** | Both are distribution shapes with no adopter asking yet | CLI-as-MCP: the Insurance sample's first run · packaging: the first brownfield adopter | the shape is exercised by that adopter's own flow, not by a demo we write for it |
 | T7 | **`Goldpath.Ai`** (runtime AI as an opt-in module, ADR-0011) | The console phase owns the calendar; the RFC is written and the ADR is accepted | after the UI phase | the module composes in and out cleanly (a manifest without it has NO AI code), and its evals run in the same lane as the rest |
+| T10 | A **token the console holds** (browser-side OIDC) | The console is a client of the app's own floor: served behind it, it needs no identity library and no per-adopter authority/clientId. Building one is a different product decision | an adopter whose services cannot share an auth boundary or allow the console's origin in CORS | the console signs in once and carries the token to every service, with the same honesty about refusals it has now (console RFC §3 auth, case 3) |
 | T9 | Client-side ROUTES for the console (a section is state today, not a URL) | Nobody has asked to bookmark a panel yet, and a fake fallback is worse than none: serving the page at an arbitrary path answers 200 with a document whose relative assets resolve a directory too deep — a blank screen with a green status code | the first operator who wants to share a link to a panel | `MapGoldpathConsole` serves the page for real routes AND injects a `<base href>` so assets resolve; the served-console e2e stops asserting 404 and asserts the panel |
 | T8 | Bulk's **`?definition=` filter** on `/batches` ([#72](https://github.com/omercelikdev/goldpath/issues/72)) | The console refuses to fake it client-side: narrowing one take-bounded page would read as "no batches" while more exist | the next bulk surface change | the panel filters by definition through the SERVER, and the smoke walks a definition with more batches than one page |
 
diff --git a/ui/console/e2e/served.spec.ts b/ui/console/e2e/served.spec.ts
index 8f51914..f6ebd45 100644
--- a/ui/console/e2e/served.spec.ts
+++ b/ui/console/e2e/served.spec.ts
@@ -49,4 +49,14 @@ test.describe("the console served by the app itself", () => {
 
     expect(response.status()).toBe(404);
   });
+
+  test("the console served by an AUTH-FLOORED app refuses its own page", async ({ page }) => {
+    // The most important property of the auth story: an adopter cannot ship an
+    // unauthenticated console by accident. The page itself is behind the ops floor, so an
+    // operator without a principal never reaches a screen to be confused by.
+    const secured = process.env.GOLDPATH_SECURED_URL ?? "http://localhost:5312";
+    const response = await page.request.get(`${secured}/goldpath/console/`);
+
+    expect(response.status()).toBe(401);
+  });
 });
diff --git a/ui/console/src/ConsoleApp.test.tsx b/ui/console/src/ConsoleApp.test.tsx
index 7263cca..7992820 100644
--- a/ui/console/src/ConsoleApp.test.tsx
+++ b/ui/console/src/ConsoleApp.test.tsx
@@ -138,6 +138,21 @@ describe("the console across services", () => {
     expect(screen.queryByLabelText(/service/i)).toBeNull();
   });
 
+  it("a service that never answers is reported as unreachable, on Today and in its own screen", async () => {
+    const fetcher = (async (input: RequestInfo | URL) => {
+      const url = String(input);
+      if (url.includes("console.config.json")) return new Response("", { status: 404 });
+      throw new TypeError("network down");   // what a blocked or dead service looks like
+    }) as typeof fetch;
+
+    render(<ConsoleApp fetcher={fetcher} search="" />);
+
+    // Today says it first — a blind spot outranks everything, including nothing.
+    expect(await screen.findByText(/did not answer at all/)).toBeInTheDocument();
+    // And there is no module nav to click into: every link would say the same thing.
+    expect(screen.queryByRole("button", { name: "Runs" })).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);
diff --git a/ui/console/src/ConsoleApp.tsx b/ui/console/src/ConsoleApp.tsx
index ed3ef4e..f07a9e9 100644
--- a/ui/console/src/ConsoleApp.tsx
+++ b/ui/console/src/ConsoleApp.tsx
@@ -2,7 +2,7 @@ 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 { composedSections, isUnreachable, SECTION_LABEL, ServicePanels, type Capabilities } from "./sections";
 import { TriageHome } from "./TriageHome";
 import { loadRegistry, SAME_ORIGIN, type ServiceEntry } from "./registry";
 
@@ -79,7 +79,9 @@ export function ConsoleApp({ title = "Goldpath console", fetcher, search, now }:
 
   const nav: ShellNavItem[] = [
     { id: TODAY, label: "Today", onSelect: () => setSection(TODAY) },
-    ...composedSections(capabilities).map((module) => ({
+    // A service that never answered has no sections to offer: the nav would be a list of
+    // links that each say the same thing.
+    ...(isUnreachable(capabilities) ? [] : composedSections(capabilities)).map((module) => ({
       id: module,
       label: SECTION_LABEL[module],
       onSelect: () => setSection(module),
diff --git a/ui/console/src/adminClient.test.ts b/ui/console/src/adminClient.test.ts
index 9d5bb02..bba3ec7 100644
--- a/ui/console/src/adminClient.test.ts
+++ b/ui/console/src/adminClient.test.ts
@@ -78,14 +78,18 @@ describe("the admin client (the console's only door — the FROZEN contract)", (
     expect(capabilities.jobs).toEqual({ kind: "refused", message: undefined });
   });
 
-  it("an unreachable service yields no panels instead of crashing the console", async () => {
+  it("a service that never ANSWERS is unreachable, not module-less", async () => {
+    // A dead service and a cross-origin one whose CORS refuses this console look identical
+    // from here — fetch reports nothing more. Both are "we could not ask", which is a
+    // different sentence from "the app does not have this module", and the console must
+    // not collapse the two: only one of them means stop looking.
     const fetcher = (async () => {
       throw new TypeError("network down");
     }) as typeof fetch;
 
     const capabilities = await new AdminClient({ fetcher }).discoverCapabilities();
 
-    expect(MODULES.every((module) => capabilities[module].kind === "absent")).toBe(true);
+    expect(MODULES.every((module) => capabilities[module].kind === "unreachable")).toBe(true);
   });
 
   it("clamps take to the contract's [1,500] before the request leaves the browser", async () => {
diff --git a/ui/console/src/adminClient.ts b/ui/console/src/adminClient.ts
index c3371ad..69b0f19 100644
--- a/ui/console/src/adminClient.ts
+++ b/ui/console/src/adminClient.ts
@@ -34,7 +34,15 @@ export type Capability =
   | { kind: "present" }
   | { kind: "absent" }
   | { kind: "forbidden"; message?: string }
-  | { kind: "refused"; message?: string };
+  | { kind: "refused"; message?: string }
+  /**
+   * The probe never got an answer: the service is down, the network is in the way, or the
+   * browser blocked the call (a cross-origin service whose CORS does not allow this
+   * console's origin looks EXACTLY like this from here — fetch reports nothing more).
+   * Distinct from `absent` on purpose: "the app does not have this module" and "we could
+   * not ask" are different sentences, and only one of them is a reason to stop looking.
+   */
+  | { kind: "unreachable" };
 
 /**
  * Pulls the human sentence out of a refusal. Goldpath's own envelope says `message`;
@@ -336,6 +344,7 @@ export class AdminClient {
    *   revision R1). Reading that as "absent" would tell the operator the module is not
    *   composed, which is a lie the console must never tell.
    * - anything else non-ok → `absent`, honestly: nothing usable answered.
+   * - a call that THREW → `unreachable`: down, blocked, or cross-origin without CORS.
    */
   async discoverCapabilities(): Promise<Record<ModuleName, Capability>> {
     const entries = await Promise.all(
@@ -356,7 +365,7 @@ export class AdminClient {
 
           return [module, { kind: response.ok ? "present" : "absent" } as Capability] as const;
         } catch {
-          return [module, { kind: "absent" } as Capability] as const;   // unreachable: no panel, no crash
+          return [module, { kind: "unreachable" } as Capability] as const;   // asked, never answered
         }
       }),
     );
diff --git a/ui/console/src/sections.test.tsx b/ui/console/src/sections.test.tsx
index 2ae1c62..3e9c571 100644
--- a/ui/console/src/sections.test.tsx
+++ b/ui/console/src/sections.test.tsx
@@ -2,8 +2,11 @@ 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 capabilities = (
+  over: Partial<Record<ModuleName, Capability>> = {},
+  fallback: Capability["kind"] = "absent",
+): Capabilities =>
+  Object.fromEntries(MODULES.map((module) => [module, over[module] ?? { kind: fallback }])) as Capabilities;
 
 const client = new AdminClient({ fetcher: (async () => new Response("[]", { status: 200 })) as typeof fetch });
 
@@ -47,6 +50,14 @@ describe("one service's panels", () => {
     }
   });
 
+  it("a service that did not answer says SO — it is not an app without modules", () => {
+    render(<ServicePanels client={client} capabilities={capabilities({}, "unreachable")} section="jobs" />);
+
+    expect(screen.getByRole("alert")).toHaveTextContent(/did not answer at all/);
+    expect(screen.getByRole("alert")).toHaveTextContent(/CORS policy/);
+    expect(screen.queryByText(/composes none of them/)).toBeNull();
+  });
+
   it("composedSections keeps the standard order, whatever order the probes answered in", () => {
     expect(composedSections(capabilities({ campaign: { kind: "present" }, jobs: { kind: "forbidden" } }))).toEqual([
       "jobs",
diff --git a/ui/console/src/sections.tsx b/ui/console/src/sections.tsx
index 3eab1a1..be83302 100644
--- a/ui/console/src/sections.tsx
+++ b/ui/console/src/sections.tsx
@@ -22,6 +22,11 @@ export function composedSections(capabilities: Capabilities | null): ModuleName[
   return capabilities ? MODULES.filter((module) => capabilities[module].kind !== "absent") : [];
 }
 
+/** True when NOTHING answered — the service is down or blocking us, not module-less. */
+export function isUnreachable(capabilities: Capabilities | null): boolean {
+  return capabilities !== null && MODULES.every((module) => capabilities[module].kind === "unreachable");
+}
+
 export interface ServicePanelsProps {
   client: AdminClient;
   capabilities: Capabilities | null;
@@ -42,10 +47,20 @@ export function ServicePanels({ client, capabilities, section, now }: ServicePan
     return <p className="text-sm text-muted-foreground">Discovering capabilities…</p>;
   }
 
+  if (isUnreachable(capabilities)) {
+    return (
+      <Banner tone="danger" live="alert">
+        This service did not answer at all — it may be down, or its CORS policy may not allow this console's
+        origin. That is different from an app that composes no Goldpath module, and the console will not
+        pretend otherwise.
+      </Banner>
+    );
+  }
+
   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.
+        No Goldpath admin surface answered here — this app composes none of them.
       </p>
     );
   }
@@ -61,6 +76,12 @@ export function ServicePanels({ client, capabilities, section, now }: ServicePan
         </Banner>
       )}
 
+      {capability.kind === "unreachable" && (
+        <Banner tone="danger" live="alert">
+          {SECTION_LABEL[section]} did not answer — the service may be down, or blocking this console's origin.
+        </Banner>
+      )}
+
       {capability.kind === "refused" && (
         <Banner tone="warning">
           {SECTION_LABEL[section]} is composed here but refused this request.
diff --git a/ui/console/src/triage.test.ts b/ui/console/src/triage.test.ts
index 0dcf327..2c9d06c 100644
--- a/ui/console/src/triage.test.ts
+++ b/ui/console/src/triage.test.ts
@@ -304,6 +304,17 @@ describe("triage — what is wrong, read from the contract's own lists", () => {
     expect(rows[0].detail).toContain("the 'goldpath-ops' role is required");
   });
 
+  it("a service that never answered is named as such, not as five unreadable surfaces", async () => {
+    const dead = Object.fromEntries(MODULES.map((module) => [module, { kind: "unreachable" }])) as Capabilities;
+
+    const rows = await collectServiceTriage("claims", client({}), dead, NOW);
+
+    expect(rows).toHaveLength(1);
+    expect(rows[0]).toMatchObject({ blind: true, tone: "danger" });
+    expect(rows[0].headline).toBe("claims did not answer at all");
+    expect(rows[0].detail).toContain("blocking this console's origin");
+  });
+
   it("one unreadable surface reads in the singular, and without a message says what it can", async () => {
     const rows = await collectServiceTriage(
       "payments",
diff --git a/ui/console/src/triage.ts b/ui/console/src/triage.ts
index 0837c26..213db62 100644
--- a/ui/console/src/triage.ts
+++ b/ui/console/src/triage.ts
@@ -54,7 +54,7 @@ export async function collectServiceTriage(
   // 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";
+    return kind === "forbidden" || kind === "refused" || kind === "unreachable";
   });
 
   if (unreadable.length > 0) {
@@ -65,8 +65,14 @@ export async function collectServiceTriage(
       section: unreadable[0],
       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",
+      headline: capabilities[unreadable[0]].kind === "unreachable" && unreadable.length === MODULES.length
+        ? `${service} did not answer at all`
+        : `${unreadable.length} surface${unreadable.length === 1 ? "" : "s"} on ${service} cannot be read`,
+      detail: said
+        ? `the service said: “${said}”`
+        : capabilities[unreadable[0]].kind === "unreachable"
+          ? "it may be down, or blocking this console's origin — triage cannot speak for it"
+          : "this operator may not see them, or the request cannot be scoped",
     });
   }
 

From 7f70dd8f00d0ba056e8f3eb525244014c05a4a94 Mon Sep 17 00:00:00 2001
From: Omer Celik <omer@omercelik.dev>
Date: Mon, 27 Jul 2026 17:06:03 +0300
Subject: [PATCH 2/2] fix(console): an outage means EVERY probe threw (review
 R1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

"The service did not answer at all" fired when the first unreadable module was
unreachable and the rest were merely unreadable — so one dead probe among four honest
401s was reported as a full outage. That is the same false confidence this row exists to
prevent, reintroduced one layer up. It now reuses the shell's own predicate rather than
re-deriving a weaker one, and a mixed case is covered.

140 console tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 ui/console/src/triage.test.ts | 15 +++++++++++++++
 ui/console/src/triage.ts      | 10 +++++++---
 2 files changed, 22 insertions(+), 3 deletions(-)

diff --git a/ui/console/src/triage.test.ts b/ui/console/src/triage.test.ts
index 2c9d06c..a73d222 100644
--- a/ui/console/src/triage.test.ts
+++ b/ui/console/src/triage.test.ts
@@ -315,6 +315,21 @@ describe("triage — what is wrong, read from the contract's own lists", () => {
     expect(rows[0].detail).toContain("blocking this console's origin");
   });
 
+  it("ONE dead probe among honest refusals is not an outage — the count is told, not dramatised", async () => {
+    const mixed = Object.fromEntries(
+      MODULES.map((module, index) => [
+        module,
+        index === 0 ? { kind: "unreachable" } : { kind: "forbidden", message: "the 'goldpath-ops' role is required" },
+      ]),
+    ) as Capabilities;
+
+    const rows = await collectServiceTriage("payments", client({}), mixed, NOW);
+
+    expect(rows).toHaveLength(1);
+    expect(rows[0].headline).toBe("5 surfaces on payments cannot be read");
+    expect(rows[0].headline).not.toContain("did not answer at all");
+  });
+
   it("one unreadable surface reads in the singular, and without a message says what it can", async () => {
     const rows = await collectServiceTriage(
       "payments",
diff --git a/ui/console/src/triage.ts b/ui/console/src/triage.ts
index 213db62..b68c0e4 100644
--- a/ui/console/src/triage.ts
+++ b/ui/console/src/triage.ts
@@ -1,6 +1,6 @@
 import { deadlineVerdict, humanizeSeconds } from "@goldpath/kit";
 import { MODULES, type AdminClient, type ModuleName } from "./adminClient";
-import type { Capabilities } from "./sections";
+import { isUnreachable, type Capabilities } from "./sections";
 
 /**
  * One thing worth an operator's attention. `tone` is the semantic ramp, not a priority
@@ -65,12 +65,16 @@ export async function collectServiceTriage(
       section: unreadable[0],
       tone: "danger",
       blind: true,
-      headline: capabilities[unreadable[0]].kind === "unreachable" && unreadable.length === MODULES.length
+      // "Did not answer AT ALL" must mean exactly that — EVERY probe threw. One dead
+      // probe among four honest refusals is not an outage, and reporting it as one is the
+      // same false confidence this row exists to prevent (review R1 on this PR). The
+      // shell's own predicate is reused rather than re-derived.
+      headline: isUnreachable(capabilities)
         ? `${service} did not answer at all`
         : `${unreadable.length} surface${unreadable.length === 1 ? "" : "s"} on ${service} cannot be read`,
       detail: said
         ? `the service said: “${said}”`
-        : capabilities[unreadable[0]].kind === "unreachable"
+        : isUnreachable(capabilities)
           ? "it may be down, or blocking this console's origin — triage cannot speak for it"
           : "this operator may not see them, or the request cannot be scoped",
     });
