petrova.host is re-rooted to the console: a public shell at the apex, detail behind airlock
Date: 2026-08-06
Status: open
Supersedes: none
Superseded-by: none — current
Implements: docs/specs/2026-08-05-petrova-apex-domain-topology.md §3
Relates-to: docs/decisions/2026-07-14-spec-mcp-endpoint-and-single-registry.md,
docs/decisions/2026-08-06-landing-band-bound-to-canonical-sources.md,
docs/decisions/2026-08-06-petrova-blue-apex-landing.md
Context
Section titled “Context”PETROVA-APEX §3 rules that petrova.host sells the MCP transport at its apex
and hides the console — the only surface in the estate that answers “is the
fleet alright right now” — one path segment deep. The ruling: the console
becomes the apex, /console keeps a permanent redirect, the transport keeps
/rpc and loses its landing page, and the transport prose moves to
petrova.blog/integrations/mcp/ where its reader is.
§6 gates this on a decision doc (MR-7) because it is a routing change against a live operator surface. This is that doc. Implementing it surfaced two facts §3 did not have.
The apex route was dead code, shadowed
Section titled “The apex route was dead code, shadowed”dashboard/src/pages/index.astro was three lines — Astro.redirect("/console", 302).
It had never fired. dashboard/public/index.html (338 lines, last touched
2026-05-09) claims /, and in Astro a file in public/ wins over a routed page
at the same path. Production therefore served prose from a file nobody edits,
via a route nobody reaches.
That file also hardcoded an eight-item “Instruments” list against a registry of
42 tools. It is the same defect class as
2026-07-14-spec-mcp-endpoint-and-single-registry (a forked tool registry) and
2026-08-06-landing-band-bound-to-canonical-sources (a hand-written projection
of a countable source): committed output shadowing generated output. Fixing
the route without deleting the shadow would have changed nothing observable.
The console is behind airlock, so “cold GET” was undecided
Section titled “The console is behind airlock, so “cold GET” was undecided”dashboard/src/middleware.ts gates /console/** against the Airlock session
and admits only role === "admin". §3 says the console at / is “what is
served on a cold GET” — but a cold GET today is a redirect to a sign-in page.
§3 does not resolve this, and it is the decision the re-root actually turns on:
it determines what an unauthenticated stranger sees at the front door.
Decision
Section titled “Decision”One route, two reader classes: a public shell at /, per-repo detail behind
the session.
petrova.host/ always renders the mark, the scene line, the fleet verdict
counts and federation health. Those are aggregates — counts and timestamps, no
repo names, no reasons, no diffs. When a valid admin session is present the same
route additionally renders the per-repo cards and the per-edge federation rows
that /console used to carry.
Rejected alternatives:
| Option | Why not |
|---|---|
| Apex serves the gated console | The front door becomes a login screen. A surface whose entire thesis is writes are gated, reads are not cannot gate its own reads at the apex. |
| Keep auth, keep the static apex | Leaves the shadowed file, the eight-tool list and the buried console exactly as they are. Declines the ruling rather than implementing it. |
Public shell fed by a new /api/public/summary | A second public data path to secure, for data the page already has server-side. / is server-rendered and already ungated; aggregation happens before anything reaches the client. Fewer endpoints, same result. |
What this means concretely
Section titled “What this means concretely”dashboard/public/index.htmlis deleted. Its prose moves todocs/integrations/mcp.md→petrova.blog/integrations/mcp/, per §3’s “do not delete it; it is good copy in the wrong room”. The instrument list is generated fromhost/src/registry.tsbyscripts/sync-docs.mjs, grouped on themutatingflag rather than theact.name prefix, so it cannot drift the way the eight-item list did. The generator counts thename:declarations in the registry and throws if it extracted fewer entries than it found — a docs page that quietly ships a short list is the failure being fixed, so a loud build is the cheaper outcome. It caught two entries on its first run./console→ 308 to/, at the edge invercel.json, so it resolves before middleware and muscle memory does not land on an airlock bounce./console/**subroutes are unchanged and stay gated.dashboard/src/pages/console/index.astrokeeps no content — the fleet overview is now the authenticated branch of/. It is a three-lineredirect("/", 308)rather than a deleted file, so the redirect also holds in local dev and on any non-Vercel deploy, where the edge rule does not run. In production the edge rule resolves first and the stub is never reached.middleware.tsgains a soft-auth class of path (/,/denied): decode the session if one is present, setAstro.locals.user, never redirect. This generalises the special case/deniedalready had./was always ungated, so this adds identity, not exposure, and the admin role check still applies — a non-admin session reads the public shell. TheAUTH_DISABLEDdev bypass had to be extended to these paths too, or running locally with auth off would show the anonymous apex, which is the one branch such a run is least likely to be testing.isGatedgains one exemption:/consoleexactly, which is now a redirect and has nothing to protect./console/**is untouched.- The scene line is bound to the live sweep in
host/src/scene.ts(pure, tested). Numbers come from the newestlast_full_sweep, never fromDate.now()and never from a literal. A stale sweep renders its own date alongside its time and bands the line; no sweep at all renders no line. Stale beats wrong (§3). - The
petrova.bluemark is reused on the apex with the oxide dot’s fill bound topetrova.federation.status— nominal / degraded / down. The mark is the status light: full scale above the wordmark on the public shell, 20px beside the page title for an operator. It is bound on/only, not inShell.astro: the other console pages do not read the federation plane, and a mark that showednominalbecause its page never asked would be a status light that lies. A failed federation call readsdown, notnominal, for the same reason.
Deferred, deliberately
Section titled “Deferred, deliberately”/rpc/tools is not in this change. §3 says it must serve “whatever DEC
2026-07-14 ratifies as the single registry”, and that decision is still
status: open. Shipping a third public projection of the tool list while the
question of which registry is canonical is unresolved would re-create the exact
fork those decisions were written to close. It gets its own change, after
ratification. GET /api/rpc continues to answer with the method list in the
meantime.
Consequences
Section titled “Consequences”Load-bearing risk: an anonymous leak. The authenticated branch must be
constructed inside the session check, not merely hidden by markup. A dashboard
test renders / with no cookie and asserts that no registry.yaml slug, no
federation edge markup and no bearer token appears in the HTML. That test is the
enforcement mechanism for this decision; if it is ever deleted, the decision is
no longer in force.
The apex is now an unauthenticated SSR surface that fans out to N+3 RPC
calls, and petrova.federation.status reaches three upstreams. The anonymous
render is served Cache-Control: public, s-maxage=60, stale-while-revalidate=300
so the CDN absorbs repeat traffic; the authenticated render is private, no-store
with Vary: Cookie. The cache directive is chosen after the session branch
resolves — the failure mode being guarded is an authenticated render served from
a shared cache, which would leak far more than a slow page.
petrova.host/ is indexable for the first time. The console’s
noindex,nofollow is correct for an operator surface and wrong for a front
door, so the public branch uses its own layout and robots.txt allows / while
disallowing /console and /api.
The mark exists in two files. blue/ is a separate Astro application with
its own build and no shared package to import from, so the SVG geometry is
copied into dashboard/src/components/PetrovaMark.astro with a source pointer
in both directions. Recorded rather than left silent, because this doc opens by
naming committed-copy drift as a defect class. The mitigation is that the blue
copy is static geometry with no data binding: it has no source to drift from,
unlike a tool list or a version band. If a third surface needs the mark, extract
it to a package rather than making a third copy.
docs/runbooks/petrova-host-airlock-setup.md exists, contrary to an
assumption made while scoping this work. §3’s muscle-memory argument for keeping
the /console redirect indefinitely is therefore stronger than it appeared, not
weaker. The redirect is permanent.
Verification
Section titled “Verification”host:npm test— 18 scene assertions covering the sweep-bound clock, the stale date band, the null-sweep case, and number agreement.dashboard:npm test— 4 render assertions (anonymous leak, anonymous aggregates, stale-sweep banding, authenticated detail), 39 tests total.npm run buildemits no/index.htmlasset. The render tests drive the page through Astro’s container API, which needsgetViteConfig; that requiredvitest@3indashboard/alone (host/andcli/stay on 2.x — the packages are independent and share no runner).- The two writes that pointed operators at the old path follow the re-root:
api/auth/callback.tslands a fresh session on/instead of/console, andapi/auth/logout.tsreturns to/, which is now a real page rather than an immediate bounce back into the handoff. - Production, post-merge:
GET /200 public shell;GET /console308 →/;GET /console/phasessigned out still 302s to the Airlock handoff;GET /rpcstill answers the descriptor.
Sign-off
Section titled “Sign-off”- Subagent: petrova-control-plane (2026-08-06)
- Human countersign — the apex re-root and the two auth-handler writes above.
Appended 2026-08-09. petrova validate reports MR-7
closed/superseded decision doc lacks Sign-off block for any doc whose status is
closed or superseded and which has no ## Sign-off heading
(cli/src/verbs/validate.ts:59). This document was merged at Status: closed
without one, so the check has failed on main since 2026-08-06 and blocks every
unrelated PR. Only the missing mandatory section is added here — no prose above
it is altered, and the human line is left explicitly uncountersigned, matching
2026-06-05-add-daedalus-distribute-verb.md and
2026-08-06-petrova-blue-apex-landing.md. A countersign is an attestation and
is not an agent’s to write.
Worth an operator ruling, and deliberately not decided here: a doc at
Status: closed carrying a pending countersign is a contradiction. Either the
status should have been accepted until the countersign landed, or the validator
should treat an uncountersigned Sign-off block on a closed doc as its own
finding rather than accepting the heading’s mere presence.
Appended 2026-08-10. The operator ruled: the second option, plus the status
correction that follows from it. See
2026-08-10-countersign-gates-closed-status.md. **Status:** above is
therefore changed closed → open, which is the only line of this document
altered. That is not a silent edit of a closed decision under MR-7 — the
contradiction this note itself raised means the doc was never legitimately
closed, and the change is recorded here and ratified by a dated successor.
The work described above landed and is verified; what is outstanding is the
countersign, which is exactly what open now denotes.