0153 — One repo, several apps, each its own Vercel project

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.

  1. 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/. Root package.json workspaces become ["packages/*", "apps/*"].

  2. Website first: propflow-site moves in as apps/site. Fede's zone design carries over unchanged: the SITE_ORIGIN rewrite, 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.json stays the single file. The app's rewrites read it, and apps/site imports the same file. The cross-repo half of site-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-site is kept and repointed to this repo with Root Directory apps/site. The propflow-site repo is archived after cutover.

  3. 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 only apps/**.

  4. 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.ts app-local;
    • move src/lib and agents/clara behind 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 into apps/internal at that step.

  5. 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.

  6. 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.

Consequences

Alternatives considered

Amendment 2026-11-02: dev tools are a thin window over one runner API

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

  1. 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.
  2. 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-gate for this reason.
  3. 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.
  4. 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 → startBenchRobotCall is the existing example. Short runners (one Clara turn, one tool call) may return their result inline with the handle.
  5. apps/internal is 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.
  6. 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 (refuseUnpassedSession re-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 use PIPELINE_PREVIEW_ADMIN_API_SECRET (as the eval-loop skill does today). The non-production bypass is wider than localhost. With no bearer header, the helper admits every request whenever NODE_ENV !== 'production', so any next dev server 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=Lax and the window calls the runner API on the same host. apps/internal reaches 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.
  7. Viewers move to apps/internal and read product APIs. No viewer is deleted for low page views.
  8. 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/run and api/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 putting src/lib and agents/clara behind workspace packages.

Migration: one runner at a time

Each runner is one PR (or a short vertical series) that:

  1. makes the product route the canonical runner (start → handle → status);
  2. switches the CLI scripts and skills to call that route;
  3. switches the UI to that route;
  4. deletes the duplicate path in the same PR (the repo's one-source-of-truth rule): the in-process script, the second route, the .local copy;
  5. updates the agentflow/SYSTEMS.md entry 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:

  1. Robot calls. Three paths: the bench console (Temporal workflow), the evals page (api/admin/evals/property/run, dev-server only) and scripts/voice-harness/drivers/l3-robot-call.ts (in-process, used by the robocall skill). The CLI's own header says its "only dial a bench: true line" 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.
  2. Test inbound SMS. api/simulate/sms (integration suites) and api/admin/pipeline-test (Pipeline Lab) wrap the same dispatchInbound with different guards. Fold simulate/sms into pipeline-test, which CLI callers already use.
  3. Golden / Maestro eval. executor/run-session.ts (evals page, dev-server only) and scripts/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-queue in SYSTEMS.md reads run-golden-eval.ts verdict lines, so its signals move in the same PR. Status 2026-11-03: done. One orchestration (golden-sms-run.ts, turnover in golden-turnover-run.ts) runs in goldenEvalWorkflow on the golden-eval worker (launchd on the mini). POST/GET/PATCH/DELETE /api/admin/dev/evals/golden starts, reads, steps and aborts it. The evals page and the CLI are thin clients of that route, and run-session.ts with its route.local.ts tree 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

Amendment 2026-11-02 (b): how an app deploys, and how apps/internal shares code before the packages exist

  1. Deploy (replaces Decision §3's Ignored Build Step). An app project does not build from Vercel's git integration: vercel.json sets git.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 to main. Its paths: 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 variable INTERNAL_VERCEL_PROJECT names the project.
  2. Code sharing for apps/internal (until Decision §4's packages exist). apps/internal imports the product's modules through a tsconfig @/ alias into src/, not through packages/*. Nothing copies code. The boundary a package would enforce is held by src/__tests__/internal-app-import-graph.drift.test.ts: no src/lib/data, integrations, Temporal, product route file or backend SDK is reachable from the app, and ci-internal.yml's paths: must cover every product file it reaches. That test's @sensor exit names the package boundary as its successor.

Amendment 2026-11-02 (c): product pages' CLI twins read as a view-only person

  1. 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.
  2. 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.
  3. 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.
  4. Registry. scripts/lib/product-twins.ts (one module per area), guarded by src/__tests__/product-twins.drift.test.ts (every src/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

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

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.

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

  1. 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/me must 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.
  2. 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.
  3. Two fences, neither trusting the other. The route: the presser decides only where isProvablyTestBuilding holds (building isTest AND a sandbox or staff-created test company). The CLI, before the presser signs in: --org is 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 a ws-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

  1. 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 marked door: '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.
  2. The door. A dev-tools read marked door: 'session' signs in as smoke-nightly@ (scripts/lib/admin-door.ts), with its own TOTP seed (SMOKE_NIGHTLY_*, the GitHub Actions secrets nightly.yml already maps). Product reads are unchanged: they stay on view-only smoke@, because product routes scope by company, not by the admin roster.
  3. 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 by scripts/__tests__/admin-door.test.ts.
  4. 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 under companies/[id]) reads through the same door. Derived, not listed: rosterGatedRoute reads the GET gates the admin-scope inventory already pins against source. Routes that admit smoke@ without the roster (the held-message reads the write twins' building fence uses) stay on smoke@.
  5. Least privilege, stated. Three dev-tools session reads (agent-traces, integrations-buildings, dev-email-ingestion) read product routes and would still answer smoke@. They move with the rest: one door per registry keeps the pin a single rule, and every read through it is a GET.
  6. 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, CDP Fetch, 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).