From fb850f36b9e248d10e4d506771ded8a557a1f813 Mon Sep 17 00:00:00 2001
From: Omer Celik <omer@omercelik.dev>
Date: Mon, 27 Jul 2026 17:40:36 +0300
Subject: [PATCH] =?UTF-8?q?release:=200.1.0-preview.4=20=E2=80=94=20the=20?=
 =?UTF-8?q?console=20train?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The console ships: `Goldpath.Console` maps the built single page from the adopter's own
management head, behind the same ops floor as the admin surfaces, with the cross-service
registry coming from configuration. And the tenancy misread that the console's own gate
uncovered goes with it — a single-tenant app that merely composed messaging had its admin
surfaces refusing since preview.3.

Version bump, PublicAPI roll (16 entries), CHANGELOG, and an upgrade guide that says what
it is: no breaking changes, plus how to take the console.

Two release-path fixes the nightly caught while this was being prepared:
- the console package's "refuse to pack without a built console" gate fired on EVERY pack,
  including the local source-feed pack every GM shape does — an unrelated proof suddenly
  needed Node. It is now armed by the publishing path (`release.yml` passes the property),
  which is the only place an empty console could actually reach an adopter. Both halves
  verified: armed and empty → the error; unarmed → packs clean.
- the console smoke's postgres wait was 60s, which a nightly runner doing nine other jobs
  exceeded on a cold pull. 120s, and rabbit 180s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 .github/workflows/release.yml                 |  4 +-
 CHANGELOG.md                                  | 31 +++++++++++++
 Directory.Build.props                         |  2 +-
 docs/upgrades/0.1.0-preview.4.md              | 46 +++++++++++++++++++
 .../PublicAPI.Shipped.txt                     |  2 +
 .../PublicAPI.Unshipped.txt                   |  2 -
 .../Goldpath.Console/Goldpath.Console.csproj  |  7 ++-
 .../Goldpath.Console/PublicAPI.Shipped.txt    | 12 +++++
 .../Goldpath.Console/PublicAPI.Unshipped.txt  | 12 -----
 .../PublicAPI.Shipped.txt                     |  2 +
 .../PublicAPI.Unshipped.txt                   |  2 -
 scripts/console-smoke.sh                      |  6 ++-
 12 files changed, 107 insertions(+), 21 deletions(-)
 create mode 100644 docs/upgrades/0.1.0-preview.4.md

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 50a6f2a..5362d75 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -47,7 +47,9 @@ jobs:
           # pack re-verifies each project's own Release output deterministically.
           for p in packages/*/*.csproj analyzers/Goldpath.Analyzers/Goldpath.Analyzers.csproj \
                    tools/Goldpath.Cli/Goldpath.Cli.csproj templates/Goldpath.Templates.csproj; do
-            dotnet pack "$p" --nologo -c Release -o out
+            # -p:GoldpathConsoleRequired=true arms the console package's own gate: this is
+            # the path that PUBLISHES, so this is where an empty console must stop the train.
+            dotnet pack "$p" --nologo -c Release -o out -p:GoldpathConsoleRequired=true
           done
           ls out
       # Login LAST-MINUTE: the exchanged key lives ~1 hour, so it must be born next to
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68d5cf5..53311ad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,37 @@
 All notable changes to the Goldpath packages are documented here.
 Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning: SemVer.
 
+## [0.1.0-preview.4] - 2026-07-27
+
+The console train. Goldpath ships an operations console — served by the adopter's own
+management head, driving the frozen admin contract with the operator's own credentials —
+plus the tenancy fix that the console's own gate uncovered. Upgrade guide:
+`docs/upgrades/0.1.0-preview.4.md` (no breaking changes).
+
+### Added
+- **`Goldpath.Console` — the operations console, mapped by your app.**
+  `app.MapGoldpathConsole()` serves the built single page from embedded assets, behind the
+  SAME ops floor as the admin surfaces. Adopters never run Node: the dist is built by
+  Goldpath's CI and shipped inside the package, which refuses to pack without it. The
+  cross-service registry is configuration (`AddService(name, adminBaseUrl)`), not a file
+  beside the dist.
+- **The console itself** (`ui/console`, `ui/kit`): a TODAY triage screen across every
+  configured service, then panels for all five modules — runs (trigger/pause/rerun/replay),
+  bulk intake (upload → the engine's validation report → the four-eyes gate), the campaign
+  governor (pacer numbers + live throttle + pause/resume/abort), notification evidence
+  (masked recipients, three lenses, no verbs by contract) and archival (chain verification,
+  keyed retrieval, hold/lift/erase). Capability-lit: a module the app never composed has no
+  panel, and one that refuses says why in the SERVER's words.
+
+### Fixed
+- **Multi-tenancy is a MARKER, not "some `ITenantContext` exists".** Composing a broker
+  registers an `ITenantContext` for message-scoped propagation, and the admin seam read
+  that as "this app is multi-tenant" — so a SINGLE-tenant app that merely added messaging
+  had its admin surfaces refuse: 400 on the tenant-scoped ones, 403 on campaign. Shipped in
+  preview.3; invisible because the reference app composes multitenancy. `AddGoldpathMultiTenancy`
+  now registers `GoldpathMultiTenancyMarker` and the seam asks for that. Multi-tenant
+  behaviour is unchanged.
+
 ## [0.1.0-preview.3] - 2026-07-25
 
 The security train — every actionable finding of the 2026-07-21 independent audit's
diff --git a/Directory.Build.props b/Directory.Build.props
index 97e27dc..77f6123 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -11,7 +11,7 @@
 
   <PropertyGroup>
     <VersionPrefix>0.1.0</VersionPrefix>
-    <VersionSuffix>preview.3</VersionSuffix>
+    <VersionSuffix>preview.4</VersionSuffix>
     <Authors>Omer Celik</Authors>
     <PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
     <Company>Omer Celik</Company>
diff --git a/docs/upgrades/0.1.0-preview.4.md b/docs/upgrades/0.1.0-preview.4.md
new file mode 100644
index 0000000..9483f55
--- /dev/null
+++ b/docs/upgrades/0.1.0-preview.4.md
@@ -0,0 +1,46 @@
+# Upgrading to 0.1.0-preview.4
+
+**No breaking changes — take it blind.**
+
+The train adds a package and fixes a tenancy misread. Nothing that worked on preview.3
+behaves differently, with one exception that can only make things work again (below).
+
+## The tenancy fix, if you were bitten by it
+
+If your app composes **messaging** but NOT **multitenancy**, preview.3's admin surfaces
+refused you: `400 no ambient tenant on a multi-tenant app` on bulk/archival/notification,
+and `403` on campaign. That was a misread — `AddGoldpathMessaging` registers an
+`ITenantContext` to propagate the tenant of a consumed message, and the admin seam took
+its presence as "this app is multi-tenant".
+
+Take preview.4 and the surfaces answer again. Nothing to change in your app.
+
+Multi-tenant apps: unchanged. The signal is now the marker
+`AddGoldpathMultiTenancy` registers, so tenant scoping (revision R1) applies exactly where
+it did before.
+
+## Taking the console
+
+```csharp
+// in your management head, next to the admin mappers
+app.MapGoldpathConsole();
+```
+
+- It sits behind the **same ops floor** as `/goldpath/admin/*` — an operator without
+  `goldpath-ops` is challenged for the console's own page, so you cannot ship an
+  unauthenticated console by accident. `exposeUnsecured: true` exists for hosts with no
+  auth at all, and logs that choice like the admin mappers do.
+- One console can drive several services:
+
+```csharp
+app.MapGoldpathConsole(configure: console => console
+    .AddService("payments", "https://payments.internal")
+    .AddService("claims", "https://claims.internal"));
+```
+
+  Each additional service must share the console's auth boundary or allow its origin in
+  CORS **with credentials** — the console carries the operator's own credentials and holds
+  no token of its own (console RFC §3).
+- The console adds no capability your API does not already expose. Everything it does is a
+  call an operator could make with `curl`, which is why it can be dropped in without a
+  review of what it might do.
diff --git a/packages/Goldpath.Abstractions/PublicAPI.Shipped.txt b/packages/Goldpath.Abstractions/PublicAPI.Shipped.txt
index 5ef74cd..2425525 100644
--- a/packages/Goldpath.Abstractions/PublicAPI.Shipped.txt
+++ b/packages/Goldpath.Abstractions/PublicAPI.Shipped.txt
@@ -56,3 +56,5 @@ const Goldpath.GoldpathHeaders.ApiKey = "X-Goldpath-Api-Key" -> string!
 Goldpath.GoldpathPolicies
 const Goldpath.GoldpathPolicies.Ops = "goldpath-ops" -> string!
 const Goldpath.GoldpathPolicies.OpsAllTenants = "goldpath-ops-all-tenants" -> string!
+Goldpath.GoldpathMultiTenancyMarker
+Goldpath.GoldpathMultiTenancyMarker.GoldpathMultiTenancyMarker() -> void
diff --git a/packages/Goldpath.Abstractions/PublicAPI.Unshipped.txt b/packages/Goldpath.Abstractions/PublicAPI.Unshipped.txt
index 4a98113..7dc5c58 100644
--- a/packages/Goldpath.Abstractions/PublicAPI.Unshipped.txt
+++ b/packages/Goldpath.Abstractions/PublicAPI.Unshipped.txt
@@ -1,3 +1 @@
 #nullable enable
-Goldpath.GoldpathMultiTenancyMarker
-Goldpath.GoldpathMultiTenancyMarker.GoldpathMultiTenancyMarker() -> void
diff --git a/packages/Goldpath.Console/Goldpath.Console.csproj b/packages/Goldpath.Console/Goldpath.Console.csproj
index 544a59b..4b8de2e 100644
--- a/packages/Goldpath.Console/Goldpath.Console.csproj
+++ b/packages/Goldpath.Console/Goldpath.Console.csproj
@@ -38,7 +38,12 @@
     <None Include="README.md" Pack="true" PackagePath="/" />
   </ItemGroup>
 
-  <Target Name="RefuseAnEmptyConsole" BeforeTargets="GenerateNuspec">
+  <!-- The gate fires on the PUBLISHING path only (`release.yml` sets the property). Local
+       and CI tooling packs the whole solution to build a source feed — GM shapes do it on
+       every run — and demanding Node there would make an unrelated proof depend on a
+       toolchain it has no use for. What must never happen is PUBLISHING an empty console,
+       and that is exactly where this fires. -->
+  <Target Name="RefuseAnEmptyConsole" BeforeTargets="GenerateNuspec" Condition="'$(GoldpathConsoleRequired)' == 'true'">
     <Error Condition="!Exists('$(MSBuildProjectDirectory)/wwwroot/index.html')"
            Text="Goldpath.Console has no built console in wwwroot/. Run scripts/build-console.sh before packing — a package that maps an empty console would 404 in every adopter's face." />
   </Target>
diff --git a/packages/Goldpath.Console/PublicAPI.Shipped.txt b/packages/Goldpath.Console/PublicAPI.Shipped.txt
index 7dc5c58..77b4762 100644
--- a/packages/Goldpath.Console/PublicAPI.Shipped.txt
+++ b/packages/Goldpath.Console/PublicAPI.Shipped.txt
@@ -1 +1,13 @@
 #nullable enable
+Goldpath.GoldpathConsoleEndpoints
+Goldpath.GoldpathConsoleOptions
+Goldpath.GoldpathConsoleOptions.AddService(string! name, string! adminBaseUrl) -> Goldpath.GoldpathConsoleOptions!
+Goldpath.GoldpathConsoleOptions.GoldpathConsoleOptions() -> void
+Goldpath.GoldpathConsoleOptions.Services.get -> System.Collections.Generic.IList<Goldpath.GoldpathConsoleService!>!
+Goldpath.GoldpathConsoleService
+Goldpath.GoldpathConsoleService.AdminBaseUrl.get -> string!
+Goldpath.GoldpathConsoleService.AdminBaseUrl.init -> void
+Goldpath.GoldpathConsoleService.GoldpathConsoleService(string! Name, string! AdminBaseUrl) -> void
+Goldpath.GoldpathConsoleService.Name.get -> string!
+Goldpath.GoldpathConsoleService.Name.init -> void
+static Goldpath.GoldpathConsoleEndpoints.MapGoldpathConsole(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! prefix = "/goldpath/console", bool exposeUnsecured = false, System.Action<Goldpath.GoldpathConsoleOptions!>? configure = null) -> Microsoft.AspNetCore.Routing.IEndpointRouteBuilder!
diff --git a/packages/Goldpath.Console/PublicAPI.Unshipped.txt b/packages/Goldpath.Console/PublicAPI.Unshipped.txt
index 77b4762..7dc5c58 100644
--- a/packages/Goldpath.Console/PublicAPI.Unshipped.txt
+++ b/packages/Goldpath.Console/PublicAPI.Unshipped.txt
@@ -1,13 +1 @@
 #nullable enable
-Goldpath.GoldpathConsoleEndpoints
-Goldpath.GoldpathConsoleOptions
-Goldpath.GoldpathConsoleOptions.AddService(string! name, string! adminBaseUrl) -> Goldpath.GoldpathConsoleOptions!
-Goldpath.GoldpathConsoleOptions.GoldpathConsoleOptions() -> void
-Goldpath.GoldpathConsoleOptions.Services.get -> System.Collections.Generic.IList<Goldpath.GoldpathConsoleService!>!
-Goldpath.GoldpathConsoleService
-Goldpath.GoldpathConsoleService.AdminBaseUrl.get -> string!
-Goldpath.GoldpathConsoleService.AdminBaseUrl.init -> void
-Goldpath.GoldpathConsoleService.GoldpathConsoleService(string! Name, string! AdminBaseUrl) -> void
-Goldpath.GoldpathConsoleService.Name.get -> string!
-Goldpath.GoldpathConsoleService.Name.init -> void
-static Goldpath.GoldpathConsoleEndpoints.MapGoldpathConsole(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! prefix = "/goldpath/console", bool exposeUnsecured = false, System.Action<Goldpath.GoldpathConsoleOptions!>? configure = null) -> Microsoft.AspNetCore.Routing.IEndpointRouteBuilder!
diff --git a/packages/Goldpath.Idempotency/PublicAPI.Shipped.txt b/packages/Goldpath.Idempotency/PublicAPI.Shipped.txt
index caad764..2c0f628 100644
--- a/packages/Goldpath.Idempotency/PublicAPI.Shipped.txt
+++ b/packages/Goldpath.Idempotency/PublicAPI.Shipped.txt
@@ -26,3 +26,5 @@ Goldpath.StoredHttpResponse.ContentType.init -> void
 Goldpath.StoredHttpResponse.StatusCode.get -> int
 Goldpath.StoredHttpResponse.StatusCode.init -> void
 static Goldpath.GoldpathIdempotencyExtensions.AddGoldpathIdempotency<TBuilder>(this TBuilder builder, System.Action<Goldpath.GoldpathIdempotencyOptions!>? configure = null) -> TBuilder
+Goldpath.StoredHttpResponse.Location.get -> string?
+Goldpath.StoredHttpResponse.Location.init -> void
diff --git a/packages/Goldpath.Idempotency/PublicAPI.Unshipped.txt b/packages/Goldpath.Idempotency/PublicAPI.Unshipped.txt
index 8cdc0ca..7dc5c58 100644
--- a/packages/Goldpath.Idempotency/PublicAPI.Unshipped.txt
+++ b/packages/Goldpath.Idempotency/PublicAPI.Unshipped.txt
@@ -1,3 +1 @@
 #nullable enable
-Goldpath.StoredHttpResponse.Location.get -> string?
-Goldpath.StoredHttpResponse.Location.init -> void
diff --git a/scripts/console-smoke.sh b/scripts/console-smoke.sh
index a8304d6..b93dd3e 100755
--- a/scripts/console-smoke.sh
+++ b/scripts/console-smoke.sh
@@ -41,7 +41,9 @@ echo "── postgres"
 docker rm -f "$PG_NAME" >/dev/null 2>&1 || true
 docker run -d --name "$PG_NAME" -e POSTGRES_PASSWORD=smoke -e POSTGRES_DB=smoke -p 55432:5432 postgres:17-alpine >/dev/null
 CONNECTION="Host=localhost;Port=55432;Database=smoke;Username=postgres;Password=smoke"
-for _ in $(seq 1 60); do docker exec "$PG_NAME" pg_isready -U postgres >/dev/null 2>&1 && break; sleep 1; done
+# 120s, not 60: a nightly runner is doing nine other things and a cold image pull plus
+# first-boot initdb has taken longer than a minute there (it did, on 2026-07-27).
+for _ in $(seq 1 120); do docker exec "$PG_NAME" pg_isready -U postgres >/dev/null 2>&1 && break; sleep 1; done
 docker exec "$PG_NAME" pg_isready -U postgres >/dev/null 2>&1 || { echo "postgres never came up:"; docker logs "$PG_NAME" 2>&1 | tail -20; exit 1; }
 
 echo "── rabbitmq (campaign RELEASES through a broker — campaign RFC D8, no stand-in)"
@@ -57,7 +59,7 @@ docker run -d --name "$MQ_NAME" -p 55672:5672 rabbitmq:4-alpine sh -c \
 BROKER="amqp://guest:guest@localhost:55672"
 # BOUNDED: an unbounded wait on a container that died turns a two-minute failure into a
 # hang with no output at all.
-for _ in $(seq 1 60); do
+for _ in $(seq 1 90); do
   docker exec "$MQ_NAME" rabbitmq-diagnostics -q check_port_connectivity >/dev/null 2>&1 && break
   sleep 2
 done
