0153 — One repo, several apps, each its own Vercel project
- Status: Accepted (Gera chose it Oct 30; apps/site is live in this repo since #24351)
- Date: 2026-10-31
- Deciders: Gera, Fede
Context
On Oct 29 Fede's CI plan (ci-two-hours-2026-10-29) approved taking the public marketing site and the dev tools out of the main app's build. On Oct 30 he built the marketing half as a Next.js multi-zone: a new repo, propflow-site, with its own Vercel project. The app (#23963) relays the 27 paths in config/site-paths.json to that project once SITE_ORIGIN is set. That variable is not set, so nothing is live yet.
Fede then asked Gera to pick between the options on ci-split-or-one-repo-2026-10-30. Gera chose "Same repo, but the marketing site becomes its own app" (DM, Oct 30, 7:19 PM CT). He wants the same pattern for dev tools and anything else that can stand on its own: more Vercel projects, one repo.
An import map of origin/main at 5db2d0a5f83 measured the seams:
| Area | Files it needs | Only it needs | Notes |
|---|---|---|---|
| Website | 924 (pages and routes) | 190 | No data layer, no Temporal. Clean. |
| Internal tools (Admin → Dev, /agents, Maestro) | 3,883 | 771 | Needs 73% of the product's code (Clara, simulate and bench routes) |
| Product | 5,489 | 2,302 |
Two shared startup files tie every app to the product today. Middleware loads the /api/auth/me handler in-process, which pulls in 607 files. instrumentation.ts reaches 2,488 files through the SQS consumer.
Decision
propflowai stays one repo. Pieces that can stand alone become their own Next.js app under apps/<name>, each deployed as its own Vercel project. They share propflowai.co through multi-zone rewrites and share code through packages/*, never by copying.
The product app stays at the repo root. It does not move to
apps/, because that would touch every path in the ~92 workflow files under.github/workflows/. Rootpackage.jsonworkspaces become["packages/*", "apps/*"].Website first:
propflow-sitemoves in asapps/site. Fede's zone design carries over unchanged: theSITE_ORIGINrewrite,site-paths.json, the CSP zone host, the noindex guard for non-public hosts,hosts.ts, and the cache parity. Only two things change:- Where the code lives: the site's files sit in this repo instead of a sibling repo.
- Shared UI: the UI kit, brand and layout pieces come from
packages/*instead of copies.
One path list, not two.
config/site-paths.jsonstays the single file. The app's rewrites read it, andapps/siteimports the same file. The cross-repo half ofsite-zone-rewrites.drift.test.ts(the sibling-checkout and pinned-hash comparison) is deleted, not repointed, because there is no second copy to compare against.The Vercel project
propflow-siteis kept and repointed to this repo with Root Directoryapps/site. Thepropflow-siterepo is archived after cutover.Every app project has an Ignored Build Step. It builds only when its own folder or a
packages/*it uses changes, so a product merge never rebuilds the site. The main project ignores changes that touch onlyapps/**.Internal tools come second, after the shared code is a package. Because they need 73% of the product, the order is:
- make the auth check in middleware stop loading the route handler;
- make
instrumentation.tsapp-local; - move
src/libandagents/clarabehind workspace packages; - then move Admin → Dev, /agents and Maestro into
apps/internal, at the same URLs.
propflow-marketing(the staff marketing dashboard, moved out Oct 31) folds intoapps/internalat that step.Docs, ingest and the factory stay in their own repos. They do not move in. They may later appear under propflowai.co as zones behind the app's sign-in. That needs its own ADR.
Merge lines. GitHub allows one merge queue per branch, so in one repo a site PR waits in the same queue as product PRs. To keep the site from being slower than it would be in its own repo:
- First: a fast lane for PRs that touch only
apps/site/**. Its own checks run, and then it merges without the product queue, the same mechanism as the express lane. - Later: trial a path-partitioned merge queue (Trunk, Mergify or Aviator), with one lane per app.
- First: a fast lane for PRs that touch only
Consequences
- The website ships independently: it builds and deploys on its own, and the fast lane keeps a marketing-only PR from waiting behind the product queue.
- One copy of the UI. Brand components cannot drift between the site and the app, the main risk of the separate-repo route.
- More Vercel projects to watch. Each one needs its Ignored Build Step, or Vercel build spend rises. Vercel is already a watch item: the CI plan page (ci-two-hours-2026-10-29) flags about $15–19 a day against a $6.67 budget after the 5-minute deploy window.
- The site's tests and lint run in this repo's CI. The sibling-checkout half of
site-zone-rewrites.drift.test.tsis removed (see Decision §2); the rest of that test stays. - Fede's open items carry over unchanged:
- the preview-host traffic decision;
- checking
x-forwarded-hostbefore cutover; - moving
sitemap.xml,robots.txtand the OG image into the zone.
- Internal tools are a multi-PR effort because of the shared-package step. Moving them before that would copy most of the product.
Alternatives considered
- Separate repo per app (
propflow-site,propflow-marketing). It ships fast and needs no queue work, but it copies shared UI that then drifts, and it splits one product across several repos. Not chosen: Gera prefers one repo. - Leave everything in one app, with path-based CI routing only. Cheapest, but every site change still waits for a full Vercel rebuild of the app (an estimate, not measured: about 12–15 minutes against 2–3, from the ~8.7 min median web deploy on the CI plan page plus the queue).
- Move the product into
apps/webtoo. It's the cleanest layout, but it means rewriting most CI paths for no user-visible gain. - Kubernetes (EKS) for CI runners. Out of scope here. Fede's data shows a spot machine arrives in about 20 seconds, so machines are not the bottleneck.
Amendment 2026-11-02: dev tools are a thin window over one runner API
- Deciders: Gera
- Inventory: every page under Admin → Dev,
/admin/dev/insight,/agentsandcomponents/maestro, read at82c97b720ab.
Why
Gera, 2026-11-02: the dev tools "are built to be like 5% UI, 95% CLI able… really like Claude that can configure this… more like a Claude plugin rather than a dev tool". The UI is "a window for me to see it's actually doing the right thing or status so I can vibe check". The goal is "clean code, clean projects, thin but efficient, correct, scalable and sustainable", and resources are not a constraint.
Today the CLI and the UI often reach the same tool by different code. A robot call started from the bench console goes through a Temporal workflow, the same call from the robocall skill runs in-process with stricter destination checks, and a third copy lives on the evals page. What Gera sees is then not what Claude ran. That gap is a correctness bug, not a style problem.
Decision
- Two kinds of dev tool.
- A viewer reads data and shows it.
- A runner executes product code that does something: a Clara turn, a call, a workflow, a send, a property-software or database write. A page with buttons that run things is both: a viewer with runner buttons.
- Runners execute inside the product app, behind one internal runner API. The product owns the send doors, the gatekeeper (ADR-0139, ADR-0149) and the outbound record (ADR-0119). A runner outside the product would bypass all three, and an unrecorded send is treated as an incident. The tools runner already passes through
maintenance-module-gatefor this reason. - The CLI calls the same runner API. No script imports a runner's orchestration and runs it in-process. Claude's scripts and skills, the dev-tools window and the nightly benches all start a run the same way, so a run Claude started is a run Gera can see.
- The contract is start, then read. A runner endpoint accepts a start request and returns a run handle at once. Status and results are read from the handle (poll, or an SSE stream). A runner that needs more than one function invocation (booting Temporal workers, a 3-minute phone call, an eval grid) starts a durable Temporal workflow on a fleet worker and the handle is the workflow id. It never runs inside the request.
POST /api/admin/dev/bench/robocall→startBenchRobotCallis the existing example. Short runners (one Clara turn, one tool call) may return their result inline with the handle. apps/internalis the window. Its pages are viewers plus "run" buttons and status. They fetch product APIs and the runner API. It holds no runner code of its own.- Auth. The existing
authorizeAdminOrBearer(src/lib/platform/security/authorize-admin-or-bearer.ts) is the runner API's only gate:- Staff session. A platform administrator's session, with the explicit platform-administrator assignment, not impersonating, with MFA passed at the current epoch (
refuseUnpassedSessionre-asks it on every request). This is how the window authenticates. - Service token for Claude.
Authorization: Bearer $ADMIN_API_SECRET, constant-time compared and fail-closed in production. Claude's CLI reads it from.env.local. Preview deployments usePIPELINE_PREVIEW_ADMIN_API_SECRET(as theeval-loopskill does today). The non-production bypass is wider than localhost. With no bearer header, the helper admits every request wheneverNODE_ENV !== 'production', so anynext devserver is open to whoever can reach it. That is fine on a machine's loopback interface. A dev server reachable on the tailnet or any shared network would let anyone there start runners that place calls and send messages. A dev server that serves the runner API binds to loopback only. Before a runner that places calls or sends messages can be reached from a dev server, the bypass narrows to loopback requests or requires the bearer. - Same origin, always. The cookie path is CSRF-safe only because the session cookie is
SameSite=Laxand the window calls the runner API on the same host.apps/internalreaches it through the multi-zone rewrite on propflowai.co, never from a separate origin. If that ever changes, the runner API becomes bearer-only first.
- Staff session. A platform administrator's session, with the explicit platform-administrator assignment, not impersonating, with MFA passed at the current epoch (
- Viewers move to
apps/internaland read product APIs. No viewer is deleted for low page views. - This changes §4's order for dev tools. Most of what made internal tools need 73% of the product was runner code pulled into the pages' import graph:
api/simulate/sms(2,439 files),api/admin/dev/bench,api/admin/dev/tools/runandapi/admin/pipeline-test/end-call(2,137 each). Those stay in the product. The pages that move are viewers, so moving them no longer waits on puttingsrc/libandagents/clarabehind workspace packages.
Migration: one runner at a time
Each runner is one PR (or a short vertical series) that:
- makes the product route the canonical runner (start → handle → status);
- switches the CLI scripts and skills to call that route;
- switches the UI to that route;
- deletes the duplicate path in the same PR (the repo's one-source-of-truth rule): the in-process script, the second route, the
.localcopy; - updates the
agentflow/SYSTEMS.mdentry when a catalogued system's signals read the old entry point.
Order, by real divergence today, then by how much of the canonical path already exists:
- Robot calls. Three paths: the bench console (Temporal workflow), the evals page (
api/admin/evals/property/run, dev-server only) andscripts/voice-harness/drivers/l3-robot-call.ts(in-process, used by therobocallskill). The CLI's own header says its "only dial abench: trueline" guarantee does not hold for the other two, so the same scenario can dial a different number. Target: the bench route's workflow, with the CLI's preconditions and destination rule moved into it. - Test inbound SMS.
api/simulate/sms(integration suites) andapi/admin/pipeline-test(Pipeline Lab) wrap the samedispatchInboundwith different guards. Foldsimulate/smsintopipeline-test, which CLI callers already use. - Golden / Maestro eval.
executor/run-session.ts(evals page, dev-server only) andscripts/temporal/run-golden-eval.ts(nightly) are two copies of the same orchestration. Target: one durable workflow on the fleet's eval worker, started by both.nightly-eval-queuein SYSTEMS.md readsrun-golden-eval.tsverdict lines, so its signals move in the same PR. Status 2026-11-03: done. One orchestration (golden-sms-run.ts, turnover ingolden-turnover-run.ts) runs ingoldenEvalWorkflowon the golden-eval worker (launchd on the mini).POST/GET/PATCH/DELETE /api/admin/dev/evals/goldenstarts, reads, steps and aborts it. The evals page and the CLI are thin clients of that route, andrun-session.tswith itsroute.local.tstree is deleted (#24660, #24731, #24714, #24721, #24748, #24758, and part 4b).
Then bench reset (resetTestIdentity is the door; reset-unknown-caller.ts, willows-leasing-reset.ts and reset-property-test-data.ts copy its fences) and the status CLI (scripts/status-cli.ts reads the sections directly).
The status CLI is done: npm run status is the status page's CLI twin and reads GET /api/admin/status/all; scripts/status-cli.ts is deleted. Every dev-tools page's twin is a row in scripts/lib/cli-twins.ts (docs/runbooks/cli-twins.md).
Already on the pattern, the references: Pipeline Lab (tour-decider-e2e, journey-harness and the eval-loop skill call /api/admin/pipeline-test), the tools runner (npm run eval:tools calls /api/admin/dev/tools/run), the front-gate simulator, api/test/* and api/canary/*.
Inventory summary
31 pages: 17 viewers, 3 runners (Pipeline Lab, tools, integrations inspector), 11 both. Three of the runner pages (/agents, /agents/[sessionId], Agent Smith) drive the fleet relay, not product code.
| Duplicate | UI path | CLI path | Divergence |
|---|---|---|---|
| Robot call | bench → Temporal workflow; evals → placeAndPersistAcceptance |
l3-robot-call.ts in-process |
destination rule differs |
| Test inbound SMS | pipeline-test → runPipeline |
simulate/sms → dispatchInbound |
different pre-dispatch guards |
| Golden / Maestro eval | run-session.startRun |
run-golden-eval.ts |
two orchestration copies |
| Bench reset | resetTestIdentity |
three reset scripts | fences copied by hand |
| Admin status | readAllSections (cached) |
status-cli.ts direct reads (deleted; npm run status reads the route) |
CLI-only drift logic (removed) |
api/grading/* is not a dev tool: the conversation page's rating control calls it, so it stays in the product. api/dev/persona is read by the root layout's persona switcher and by /api/auth/me, so it stays reachable from the product too.
Consequences
- What Gera sees on the window is the run Claude started, because there is one door.
- Runners that boot workers or place calls need a fleet worker for their workflow. The eval worker exists for the nightly queue; the runner API starts work on it rather than shelling into a script.
- Each migration deletes code. A runner PR that adds the canonical path without deleting the old one is not done.
ADMIN_API_SECRETis a machine credential with full runner reach. Narrowing it (per-runner scopes, rotation) is a separate decision; this amendment does not widen it.
Amendment 2026-11-02 (b): how an app deploys, and how apps/internal shares code before the packages exist
- Deciders: the cicd lane under Gera's standing go for ADR-0153; recorded here per ADR-0004 because it changes two Decision items.
- Deploy (replaces Decision §3's Ignored Build Step). An app project does not build from Vercel's git integration:
vercel.jsonsetsgit.deploymentEnabled: false, and the app's own workflow (ci-site.yml,ci-internal.yml) creates the production deployment through the Vercel API after its checks pass on a push tomain. Itspaths:filter does what the Ignored Build Step was for (a product-only merge never deploys the app), and it compares the whole push, where Vercel's shallow clone saw only the newest commit and missed a site change (#24455).ci-internal.yml's deploy job is skipped until the repo variableINTERNAL_VERCEL_PROJECTnames the project. - Code sharing for apps/internal (until Decision §4's packages exist). apps/internal imports the product's modules through a tsconfig
@/alias intosrc/, not throughpackages/*. Nothing copies code. The boundary a package would enforce is held bysrc/__tests__/internal-app-import-graph.drift.test.ts: nosrc/lib/data, integrations, Temporal, product route file or backend SDK is reachable from the app, andci-internal.yml'spaths:must cover every product file it reaches. That test's@sensorexit names the package boundary as its successor.
Amendment 2026-11-02 (c): product pages' CLI twins read as a view-only person
- Deciders: the cli-twins-product lane under Gera's 2026-11-02 rule (every page has a CLI twin); recorded here per ADR-0004 because it adds a second auth door beside §6.
- Why a second door. Product routes (conversations, maintenance, residents, leasing, settings…) scope every row by the signed-in PERSON: their grants, then the selection re-minted from the request's own
org/prop. The machine bearer of §6 has no person, so admitting it would mean inventing a scope for it in every route, a second way into customer data. - The door. A product page's twin signs in as the view-only smoke login (ADR-0069,
docs/runbooks/smoke-mfa-exempt.md: a platform administrator in production whose every write is refused) and sends the page's own GET with that session cookie. No route, gate or middleware entry changes; the CLI is one more signed-in reader of the same route. The selection is passed through untouched, so a twin answers for exactly the companies it names, "all" included, and never mixes in another. - Writes are a later decision. The view-only login cannot write by construction. A product write twin will sign in as a person allowed to write, through the page's own route and its mode and hold rails; it is not decided here.
- Registry.
scripts/lib/product-twins.ts(one module per area), guarded bysrc/__tests__/product-twins.drift.test.ts(everysrc/app/(workspace)page has a twin or a shrink-only pending row). The dev-tools registry and its bearer door (cli-twins.ts) are unchanged.
Amendment 2026-11-02 (d): a hop between zones is a full page load
- Deciders: the zone-soft-nav lane, on Gera's report "the page does a hard reload when I change dev tool"; recorded here per ADR-0004.
The trade-off. Two Next.js apps answer on propflowai.co. Moving between pages of ONE app is a soft navigation (the frame stays, only the page body changes). Moving between the two apps is a full page load: that is how Next.js multi-zones work, and no setting changes it. It is the cost of deploying the dev tools on their own.
Measured on prod, 2026-11-02 (scripts/ui-capture/zone-hop-check.ts, the smoke login, read-only): every hop inside one zone was soft. Every hop across zones was a full load, AND it paid a wasted round trip first: the client router fetched the target page's RSC payload from the other app (the product rendered it and answered 200), could not use it, and only then loaded the document.
The fix (this amendment's PR). A link that crosses zones goes straight to a document load. The decision lives in one place, src/lib/platform/zones.ts, which reads config/internal-paths.json (the same list the product's rewrites and apps/internal's gate read) and decides from the page you are on. IntentPrefetchLink, the link the side rail, the top-bar trail and the dev-tools hub render, asks it on click and on hover: across zones it loads the document and prefetches nothing; inside a zone it is the soft <Link> it always was.
Which hops are full loads today (the list in config/internal-paths.json is the authority; this is it on 2026-11-02). Every hop between the hub or a moved viewer (/admin/dev, access-inspector, agent-smith, agent-traces and a trace, brain, email-templates, faq-asks, an insight chart, pms-inspector, traffic) and any dev tool not yet moved (pipeline-lab, bench, email-ingestion, integrations-inspector, components, metrics, status, evals, atlas, tools, work-orders, plus the dev-only doc-traffic page), in either direction. Also every hop between a moved viewer and a product page outside dev tools: the rail's other sections, and the product pages that open a trace (the work-order timeline, email-ingestion, evals, Atlas, Pipeline Lab).
When they go away. The dev-tool-to-dev-tool hops disappear once every dev-tools viewer has moved into apps/internal; each move adds its path to config/internal-paths.json, and the links follow with no code change. That move is the viewer-migration lane's work, not this one. Hops between the dev tools and the rest of the product (the rail's other sections, a trace opened from a work order) stay full loads for good, because those pages live in the other app by design. Some of those product-side links use router.push or a bare <Link> rather than IntentPrefetchLink, so they still pay the wasted round trip; moving them onto the shared link is a follow-up.
Amendment 2026-11-03: the product goes live only after the zone serves its paths
- Deciders: the zone-deploy-order lane, from a risk a helper observed during the viewer moves; recorded here per ADR-0004 because it orders two apps' production deploys.
The gap. The product rewrites every path in config/internal-paths.json to the dev-tools zone. The two deploy on separate tracks: the zone from main (ci-internal.yml, after its own checks), the product from release (deploy-web.yml). A PR that moves a page adds its path to the list and deletes the product's copy in one commit. If the product built from that commit goes live before a zone deployment that has the page, the product rewrites the path to a zone that answers 404, for minutes, or for good if the zone's deploy failed or was cancelled (the cancellations #24751 stopped).
The decision: a gate in the product's promote. deploy-web.yml's vercel-promote job runs scripts/ci/zone-paths-gate.sh after choosing the target commit and before creating the deployment. It reads the list at the target commit and the list at the commit the zone's live production deployment was built from (Vercel API, newest READY production), and passes when the first is a subset of the second. Otherwise it polls every 30 s; after 20 min it fails the promote, which alert-deploy-failed posts to #alerts. No flag: the step is the path.
- Path sets, not commits. The zone deploys only when its own inputs change, so its live commit is normally older than the product's; a commit match would block nearly every product deploy.
- Not covered: a page moving back OUT of the zone. Taking a path out of the list passes this gate (the zone's live list is a superset), but the zone's proxy reads the same list, so its next deploy from
mainstops serving the path while the product, shipping later fromrelease, still rewrites it there. No page moves that way today. If one does, it needs its own ordering (the zone keeps serving the path until the product is live without the rewrite); a reverse gate on the zone deploy is not the answer, because it deadlocks against this one on a commit that adds one path and removes another. - It blocks only in the window it exists for. Zone breakage that adds no path leaves the lists equal and passes. When the zone cannot deploy a moved page, the way out is a commit that takes the path back out of the list, not a switch on the gate.
- Alternatives rejected. The product reading the zone's served paths at request time: a network dependency on every request and a second list beside the one file. Deploying the zone first and the product after it: they ship different commits from different branches, so there is no single pipeline to order.
Rollback. Delete the "Wait for the dev-tools zone to serve every rewritten path" step from deploy-web.yml and set the job's timeout-minutes back to 80. One commit, no data or setting to restore.
Amendment 2026-11-03 (e): product write twins act as the TEST presser, at TEST buildings only
- Deciders: Gera's standing rule of 2026-11-02 ("the test login may approve/Send held drafts at TEST buildings only, never at a real customer, so agents can click through test proofs themselves"), applied by the cli-twins-product-2 lane; it settles (c) item 3.
- Who writes.
npm run -s app -- do <write> <id> --org <TEST company>signs in as the ONE test presser (TEST_HOLD_PRESSER_USER_ID,src/lib/domain/properties/test-hold-presser.ts): an email one-time code read from the PropFlow test mailbox, then its TOTP. After sign-in/api/auth/memust name that id or nothing is sent. The view-only smoke login stays view-only; its kept session and the presser's live in separate caches, so neither door can ride the other's session. - What it calls. The page's own write route with the body its button sends (
scripts/lib/write-twins.ts), so the route's mode, hold, approval and emergency rules all still run, and the row records the presser as the one who decided. No new route, no second write path. - Two fences, neither trusting the other. The route: the presser decides only where
isProvablyTestBuildingholds (buildingisTestAND a sandbox or staff-created test company). The CLI, before the presser signs in:--orgis on a pinned list of TEST companies, and the target's building, read through the pages' own GET twins as the view-only login, belongs to that company, is flagged test data, and is not aws-mirror-copy of a customer's building. An agent can never be the approver of a real customer's held message.
Amendment 2026-11-03 (f): dev-tools session reads sign in as smoke-nightly@, GET only
- Deciders: Gera, 2026-11-03 ("use smoke-nightly@ instead"), after #24885 took
smoke@off the platform-administrator roster; applied by the smoke-nightly-door lane; recorded here per ADR-0004. It amends (c) for the dev-tools registry only.
- Why. An administrator who holds a phone counts as a Propflow teammate on every customer line (#24712), so Gera took
smoke@off the roster (#24885, its role ended 2026-11-03T15:23Z). Since then every admin route answers it 403: the dev-tools twins markeddoor: 'session',zone-hop-check, and the zone's side rail ("The side menu could not load").smoke-nightly@stays on the roster on purpose: the nightly stress cleanup calls roster-gated routes. - The door. A dev-tools read marked
door: 'session'signs in assmoke-nightly@(scripts/lib/admin-door.ts), with its own TOTP seed (SMOKE_NIGHTLY_*, the GitHub Actions secretsnightly.ymlalready maps). Product reads are unchanged: they stay on view-onlysmoke@, because product routes scope by company, not by the admin roster. - The GET-only fence, in code.
smoke-nightly@is NOT a view-only login; production does not refuse its writes. So the CLI is the fence: pinned to that one address, any method but GET refused before signing in, and a kept session in its own cache directory, apart from the view-only and TEST-presser sessions, so no door can ride another's cookie. Pinned byscripts/__tests__/admin-door.test.ts. - Admin product pages too. A product twin whose route asks the roster on GET (measured 403 on
smoke@at a TEST company:companies,agent-smith-run,website-leads,customer-modules,phone-numbers,smoke-results,smoke-results-live,staff,spend, and the by-id customer pages undercompanies/[id]) reads through the same door. Derived, not listed:rosterGatedRoutereads the GET gates the admin-scope inventory already pins against source. Routes that admitsmoke@without the roster (the held-message reads the write twins' building fence uses) stay onsmoke@. - Least privilege, stated. Three dev-tools session reads (
agent-traces,integrations-buildings,dev-email-ingestion) read product routes and would still answersmoke@. They move with the rest: one door per registry keeps the pin a single rule, and every read through it is a GET. - Where it runs. The nightly login's password and seed exist only as write-only GitHub Actions secrets. Reading them back is impossible, and re-enrolling the seed is a production auth write that would break the nightly until its secret is reset. So on a machine without them the door fails closed naming the missing keys, and live checks run in a GitHub Actions job that maps the secrets (
.github/workflows/admin-door-live-check.yml, dispatch only, TEST companies only). The browser checks (zone-hop-check,rail-paint-check) sign in the same way and fail every non-GET request in the tab (scripts/ui-capture/admin-reader.ts, CDPFetch, which leaves the HTTP cache on so timings stay comparable).
Rollback. Revert the PR: dev-tools session reads go back to the smoke@ door, which answers 403 on admin routes until smoke@ is back on the roster (Gera's call only).