Files
arah2/.planning/phases/05-routing-route-display/05-05-PLAN.md
T

271 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 05-routing-route-display
plan: 05
type: execute
wave: 3
depends_on: ["02", "04"]
files_modified:
- web/test/build-artifact.test.mjs
- web/index.html
- web/src/mock/route-profiles.ts
- web/scripts/capture.mjs
- bot/test/route-privacy.test.mjs
autonomous: true
requirements: [RTNG-01, RTNG-02]
must_haves:
decisions_implemented:
- "D-11: the BA build-artifact bundle-budget gate — fixture geometry + route-model code ≤ ~200KB gzipped measured over production-shaped artifacts, printed with every run"
- "D-05: the Worker gains no /api/route — a structural gate proves bot/src never registers a route proxy and never imports the routing workspace"
- "D-15: capture signals are state tokens only — ARAHARAH-ROUTE state=drawn|pending, never coordinates; the capture legs pair the token with DOM text checks"
- "D-12: the capture harness's ?fixpair knob delivers a PINNED PUBLIC origin from the committed fixture corpus — the harness introduces no user coordinate"
- "DSGN-02: the route-gallery fidelity artifact extends the Phase 1/3 gallery pattern beside navigate_map_overview/screen.png"
truths:
- "BA9 measures the gzipped size of the fixture dataset + routing workspace sources + web route modules against a 204800-byte budget and PRINTS the measurement — the budget is visible, never discovered"
- "BA10 proves every committed pairId rides the shipped production bundle (the BA7 id-parity analog — a stale bundle without the fixtures is unshippable)"
- "The harness-only route-profiles module is provably excluded from web/dist (a named sentinel token the BA5-family scan greps for)"
- "Five new capture legs exist and pass: shell-route-fixture, shell-route-alt (post-chip-tap), shell-route-pending, shell-route-dark, route-gallery-compare — all non-blank, all state-token-raced"
- "bot/test/route-privacy.test.mjs proves the Worker stayed out of the routing path: zero /api/route tokens, zero @araharah/routing imports in bot/src"
artifacts:
- path: "web/test/build-artifact.test.mjs"
provides: "BA9 (bundle budget, printed) + BA10 (pairId parity) — the production-build gates"
contains: "BA9"
- path: "web/src/mock/route-profiles.ts"
provides: "the harness-only ?fixpair origin lookup (imports getPairOrigin from @araharah/routing; sentinel-guarded)"
contains: "ROUTE_PROFILE_HARNESS_ONLY"
- path: "web/index.html"
provides: "the ?fixpair=<pair-id> knob — the mock's telegramFix becomes the pair's pinned public origin"
contains: "fixpair"
- path: "web/scripts/capture.mjs"
provides: "the five route capture legs + the ARAHARAH-ROUTE signal race + DOM assertions"
contains: "ARAHARAH-ROUTE"
- path: "bot/test/route-privacy.test.mjs"
provides: "RP1/RP2 — the Worker-side no-route-proxy gates (D-05)"
contains: "RP1"
key_links:
- from: "web/index.html"
to: "web/src/mock/route-profiles.ts"
via: "the harness block resolves ?fixpair to a pinned origin and threads it as the mock's telegramFix"
pattern: "getRouteProfileOrigin"
- from: "web/src/mock/route-profiles.ts"
to: "@araharah/routing/src/index.ts"
via: "getPairOrigin(pairId) — the committed public fixture corpus is the ONLY origin source the harness can deliver"
pattern: "getPairOrigin"
- from: "web/scripts/capture.mjs"
to: "web/src/route/route.ts"
via: "the capture race waits on the ARAHARAH-ROUTE state token the controller emits"
pattern: "ARAHARAH-ROUTE"
- from: "bot/test/route-privacy.test.mjs"
to: "bot/src"
via: "the D-05 structural scan (no route proxy, no routing import) — the privacy-gate family pattern"
pattern: "/api/route"
---
<objective>
Close the phase with its verification gates: the BA9 bundle-budget + BA10 fixture-parity build-artifact gates over the production build, the five route capture legs driven by the new `?fixpair=` harness knob, and the Worker-side no-route-proxy privacy gates — the mechanical proof that the amended SC3 holds ("bundle-size and type gates holding").
Purpose: D-11's bundle budget is a build-artifact gate, not a hope; the UI-SPEC's five capture artifacts are the phase's visual evidence (and the DSGN-02 fidelity obligation); and D-05/D-12's privacy posture stays verifiable by reading gate output. The route legs run against the production-shaped harness bundle (the Phase 3 capture discipline — never against dev-mode).
Output: BA9/BA10 in `build-artifact.test.mjs`, `web/src/mock/route-profiles.ts` + the `?fixpair` knob in `web/index.html`, five new capture legs in `capture.mjs` (expected list 8 → 13), and `bot/test/route-privacy.test.mjs` (RP1/RP2).
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@AGENTS.md
@.planning/phases/05-routing-route-display/05-CONTEXT.md
@.planning/phases/05-routing-route-display/05-UI-SPEC.md
@.planning/phases/05-routing-route-display/05-02-PLAN.md
@.planning/phases/05-routing-route-display/05-04-PLAN.md
@web/test/build-artifact.test.mjs
@web/scripts/capture.mjs
@web/index.html
@web/src/mock/telegram-mock.ts
@bot/test/privacy.test.mjs
@routing/src/index.ts
@routing/src/data/route-fixtures.json
<capture_contract>
The five new legs (UI-SPEC §Capture Profiles — ALL existing legs unchanged; the expected list grows 8 → 13). The demo pair is selected AT RUNTIME from the committed corpus — never hardcoded: `const routePairs = JSON.parse(readFileSync(resolve(REPOROOT, "routing/src/data/route-fixtures.json", "utf8"))).pairs;` then `const demoPair = routePairs.find((p) => p.routes.length >= 2) ?? routePairs[0];` (RT10 guarantees ≥ 4 multi-route pairs, so the find succeeds; the fallback keeps the legs runnable in the degenerate case). Queries are then built from `demoPair.destinationId` / `demoPair.pairId`:
| Artifact | Query | Race + assertions |
|----------|-------|-------------------|
| `shell-route-fixture.png` | `mock=android&dest=${demoPair.destinationId}&fixpair=${demoPair.pairId}` (light) | race `ARAHARAH-ROUTE`; assert the line reads `state=drawn`; DOM: `waitForFunction` body text contains the destination's NAME (from the gazetteer entry read at capture time) AND `MIN` (the card's telemetry); 2000ms tile settle; shot |
| `shell-route-alt.png` | same profile | after the drawn race: if `.route-chip` exists (multi-route pair — the RT10-guaranteed case), `page.click(".route-chip")`, wait 800ms (instant swap + 400ms refit + margin), assert body text matches `/ROUTE \d\/\d/`; ELSE (degenerate single-route fallback) assert the no-chip law: the page contains NO `.route-chip` element; shot |
| `shell-route-pending.png` | `mock=android&dest=national-museum&fixpair=${demoPair.pairId}` | race `ARAHARAH-ROUTE`; assert `state=pending`; DOM: body contains `not wired up yet` (the pending headline, en); shot |
| `shell-route-dark.png` | route-fixture profile + `scheme=dark` | same race + the PX-02 honesty check (`data-tg-theme` === "dark" before shooting); shot |
| `route-gallery-compare.png` | — | the DSGN-02 composer (gallery-compare precedent): `shell-route-fixture.png` beside `stitch_minimalist_route_navigator/navigate_map_overview/screen.png`, columns labeled with source paths |
Harness mechanics:
- `SIGNALS` gains `'ARAHARAH-ROUTE'` (the seen-map + diagnostics strings too).
- New `waitMode: 'route'` — race the ARAHARAH-ROUTE console event (30s timeout, the SIGNAL_TIMEOUT_MS shape), then parse the captured line for `state=` and let the leg assert its expected state + DOM.
- `web/index.html` harness block: `const fixpair = q.get("fixpair");` — when present, import the profile module and resolve the pinned origin; pass `telegramFix: { lat: <origin.lat>, lng: <origin.lng>, accuracyM: 12, headingDeg: null }` (fall back to the current default fix when the pairId is unknown — an unknown pairId must not wedge the boot).
- `web/src/mock/route-profiles.ts` (HARNESS-ONLY, imported ONLY from the index.html harness block): imports `getPairOrigin` from `@araharah/routing/src/index.ts`; exports `getRouteProfileOrigin(pairId: string): { lat: number; lng: number } | null` and the sentinel `export const ROUTE_PROFILE_HARNESS_ONLY = true;`. Header comment: D-12 (the pinned origin is committed public fixture data — the harness delivers no user coordinate) + the BA-exclusion note.
</capture_contract>
<ba_gate_contract>
**BA9 — the bundle budget (D-11).** Appended to `build-artifact.test.mjs`:
- Inputs (all committed files, production-shaped by construction): `routing/src/data/route-fixtures.json`, every `routing/src/**/*.ts`, every `web/src/route/**/*.ts`.
- Measurement: sum of `gzipSync(readFileSync(f)).length` over the inputs; assert `<= 204800` (200 KiB); the failure message and a `console.log` line BOTH print the measured bytes + budget (`BA9 route bundle budget: {n} / 204800 gz bytes`) — visible every run (D-11: "enforced as a BA build-artifact gate").
- Comment in the gate: the measurement bounds the phase's ADDED payload (fixtures dominate; the minified route code's gz size is approximated by its source gz size — the approximation is conservative in the gate's favor at these sizes and the 200 KiB ceiling carries an order-of-magnitude headroom per 05-RESEARCH Finding 1).
- The gate skips with a build-first message when `web/dist` is absent (the BA-suite convention) — run after `npm run build -w theme && npm run build -w web`.
**BA10 — fixture parity (the BA7 analog).** Every `pairId` from the committed `route-fixtures.json` must appear in the concatenated `web/dist/assets/*.js` text — a stale bundle that lost the fixtures could never serve a route (assert `pairs.length >= 10` first, the "scanned a real dataset" guard pattern).
**BA5-family extension.** Add `"ROUTE_PROFILE_HARNESS_ONLY"` to BA5's token list — the harness-only route-profiles module must be dead-code-eliminated from production (its import lives inside `if (__ARAHARAH_HARNESS__)` in index.html; the define-replaces-with-false mechanism strips the branch exactly like installMockWebApp).
</ba_gate_contract>
<environment_facts>
Verified in this session — do not re-derive:
- `capture.mjs` structure: `SIGNALS` array at line 166, `captureLeg({name, query, colorScheme, grantGeo, waitMode})`, the waitMode dispatch chain (`live`/`dest`/`denied`/`settle400`/`immediate`), and the final `expected` list of 8 artifacts with the missing/undersized/failures gate — the extension points are exactly these four.
- `web/index.html`'s harness block already reads `?dest=` and threads it as `startParam`; `telegramFix` is hard-coded `{lat:3.1513,lng:101.6958}` today — the fixpair knob replaces its VALUE (never the shape).
- `build-artifact.test.mjs` reads `search/src/data/kl-gazetteer.json` via `import … with { type: "json" }` (line 47) — the fixtures JSON imports identically; BA7's per-id loop is the BA10 template.
- The mock's `telegramFix` delivery maps `{lat,lng,accuracyM,headingDeg}` → the Telegram wire shape (`telegram-mock.ts` lines 190–199) — the profile module returns the authoring shape.
- Plan 05-02 pinned `national-museum` as the pending-probe destination (RT4: never a corpus destination) — the pending leg's carrier is valid by construction.
- Plan 05-04's controller emits exactly two console tokens: `ARAHARAH-ROUTE state=drawn` / `ARAHARAH-ROUTE state=pending` (RS8-pinned).
- `bot/test/privacy.test.mjs` is the bot-side grep-gate style reference (comment-stripping + banned-token scans).
</environment_facts>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Harness query params → mock fix | `?fixpair=` selects which PUBLIC pinned origin the mock delivers. It must never accept arbitrary coordinates (a `?lat=&lng=` knob would reintroduce the coordinate-in-URL surface the fixture world exists to avoid). |
| Committed artifacts → the measured budget | The budget gate reads repo files; a silent fixture bloat (someone re-fetching with `overview=full&steps=true`) must fail the build gate, not surface as a slow webview. |
| Production build → users | Harness-only modules leaking into `web/dist` would ship mock wiring — the BA5-family sentinel scan is the tripwire. |
| Worker surface | A route proxy appearing in bot/src would violate D-05's "the Worker gains no /api/route" — the structural gate keeps the pivot honest. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-12 | Denial of Service (availability budget) | Fixture/code growth pushing the webview bundle past the D-11 budget | mitigate | BA9 fails the suite above 204800 gz bytes and prints the measurement every run; RT9 (plan 05-02) is the fixture-level early-warning tier of the same law. |
| T-05-14 | Information Disclosure | A coordinate-parameter harness knob or a coordinate-bearing capture signal reintroducing user-coordinate surfaces | mitigate | The knob accepts ONLY a pairId (resolved from the committed public corpus via `getPairOrigin` — arbitrary lat/lng params are never read); the capture legs assert the `state=` token form (no coordinates — RS8 pins the source; the legs pin the runtime); the pending leg proves an out-of-corpus pair renders the honest card, never a fabricated geometry. |
| T-05-16 | Tampering (D-05 regression) | A route proxy growing into the Worker | mitigate | RP1: zero `/api/route` tokens in comment-stripped `bot/src`; RP2: zero `@araharah/routing` imports in `bot/src` — the Worker cannot route even accidentally. |
| T-05-15 | Tampering (harness leakage) | The route-profiles harness module shipping in production | mitigate | The `ROUTE_PROFILE_HARNESS_ONLY` sentinel joins BA5's token scan — the module is provably dead-code-eliminated from `web/dist`. |
</threat_model>
<tasks>
<task type="auto" tdd="true">
<name>task 1: BA9 bundle-budget + BA10 fixture-parity + BA5 sentinel extension</name>
<files>web/test/build-artifact.test.mjs</files>
<read_first>
- `web/test/build-artifact.test.mjs` (BA1–BA8 — the gate file this task extends; BA7's loop is the BA10 template)
- `routing/src/data/route-fixtures.json` (the committed pair set BA10 asserts)
- `.planning/phases/05-routing-route-display/05-RESEARCH.md` (Finding 1 — the size math behind the budget)
</read_first>
<action>
Extend `web/test/build-artifact.test.mjs`:
- Import the fixtures dataset (`import routeFixtures from "../../routing/src/data/route-fixtures.json" with { type: "json"; }`) and `gzipSync` from `node:zlib`.
- Define the input file list: `routing/src/data/route-fixtures.json` + a recursive walk of `routing/src` (`*.ts`) + `web/src/route` (`*.ts`) (resolve paths relative to the test file like `WEB`/`DIST` are).
- `BA9` test: skip-with-build-first-message when `web/dist` is absent (the suite convention); compute the summed gz size; `console.log("BA9 route bundle budget: " + total + " / 204800 gz bytes")`; assert `<= 204800` with the measurement in the failure message.
- `BA10` test: assert `routeFixtures.pairs.length >= 10` (a real dataset guard); concatenate `web/dist/assets/*.js`; assert EVERY `pairId` appears in the bundle text (failure names the missing pairId — the T-04-39 phrasing pattern).
- BA5: add `"ROUTE_PROFILE_HARNESS_ONLY"` to the scanned token list (both the JS-assets and the HTML scans).
- Update the file-header doc-comment inventory (BA9/BA10 lines).
Build first, then run: `npm run build -w theme && npm run build -w web && npm test -w web` — ALL BA gates green including the new ones (04-04's BA7/BA8 still pass — the route family adds no crypto, no CDN, and the gazetteer ids remain).
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run build -w theme >/dev/null 2>&1 && npm run build -w web >/dev/null 2>&1 ; npm test -w web 2>&1 | tail -15</automated>
</verify>
<acceptance_criteria>
- `npm test -w web` exits 0 and the output contains `PASS BA9` and `PASS BA10`
- `grep -q 'BA9 route bundle budget' web/test/build-artifact.test.mjs` exits 0 (the printed measurement)
- `grep -q '204800' web/test/build-artifact.test.mjs` exits 0
- `grep -q 'ROUTE_PROFILE_HARNESS_ONLY' web/test/build-artifact.test.mjs` exits 0 (BA5 extension)
- `grep -q 'pairId' web/test/build-artifact.test.mjs` exits 0 (BA10 parity loop)
- `node -e "const{gzipSync}=require('node:zlib');const{readFileSync,readdirSync}=require('node:fs');const{join}=require('node:path');let t=0;const w=(d)=>{for(const f of readdirSync(d,{withFileTypes:true})){const p=join(d,f.name);if(f.isDirectory())w(p);else if(f.name.endsWith('.ts'))t+=gzipSync(readFileSync(p)).length}};w('routing/src');t+=gzipSync(readFileSync('routing/src/data/route-fixtures.json')).length;w('web/src/route');console.log('measured:',t);if(t>204800)throw new Error('over budget');console.log('BA9 preview OK')"` exits 0
</acceptance_criteria>
<done>The production build passes the budget gate (measured and printed), carries every committed pairId, and provably excludes the harness-only profile module.</done>
</task>
<task type="auto" tdd="true">
<name>task 2: ?fixpair harness knob + five route capture legs</name>
<files>web/src/mock/route-profiles.ts, web/index.html, web/scripts/capture.mjs</files>
<read_first>
- `web/index.html` (the harness block — where the knob threads)
- `web/src/mock/telegram-mock.ts` (MockOptions.telegramFix — the authoring shape the profile feeds)
- `web/scripts/capture.mjs` (SIGNALS, captureLeg waitModes, the expected list, the gallery composer)
- `.planning/phases/05-routing-route-display/05-UI-SPEC.md` §Capture Profiles (the binding artifact table)
</read_first>
<action>
**`web/src/mock/route-profiles.ts`** — per the `<capture_contract>` block: sentinel export + `getRouteProfileOrigin` wrapping `getPairOrigin` from `@araharah/routing/src/index.ts`; header comment citing D-12 and the BA5 sentinel.
**`web/index.html`** — inside the harness block: read `const fixpair = q.get("fixpair");`; after the existing mock-import, when `fixpair` is truthy dynamically import the profile module (`await import("/src/mock/route-profiles.ts")` — the `/src/` absolute form is the PROVEN harness-import path), resolve the origin, and use it as the mock's `telegramFix` lat/lng (accuracy 12, heading null); unknown pairId → keep the current default fix (log nothing). Add `fixpair` to the profile comment line listing the knobs.
**`web/scripts/capture.mjs`**:
- `SIGNALS` gains `'ARAHARAH-ROUTE'`; the seen-map + `signalDiagnostics` gain the same key.
- At the top of the leg block, load the committed corpus and pick the demo pair (`routePairs` / `demoPair` per the `<capture_contract>` block — the legs are data-driven from the committed dataset, never hardcoded pair literals).
- New `waitMode: "route"` branch: race the `ARAHARAH-ROUTE` console event (`SIGNAL_TIMEOUT_MS`); on timeout → hard failure with diagnostics; parse `state=` from the captured line; the leg's expected state + DOM assertions are leg parameters (`expectState`, `expectText: […]` — `waitForFunction` over `document.body.textContent`, 15s, the dest-leg polling shape); 2000ms settle before the shot (tile render).
- Add the five legs per the `<capture_contract>` table (fixture → alt (chip click when present) → pending → dark → gallery-compare, reusing the existing composer with `shell-route-fixture.png` in the implemented column).
- Extend `expected` to the 13-artifact list. Update the header doc-comment's Outputs inventory.
The alt leg's chip click: `page.click(".route-chip")` (first chip), then assert `/ROUTE \d\/\d/.test(bodyText)` — the `{n}/{N}` register the swapped card renders; the degenerate no-chip fallback asserts the single-route rendering law instead (UI-SPEC: single-route draws render NO chip row).
**Acceptance:** `npm run web:capture` exits 0 with 13/13 non-blank captures (`CAPTURE OK — 13/13`), including the five new legs; no existing leg regressed.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run web:capture 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- `npm run web:capture` exits 0 and its output contains `CAPTURE OK — 13/13`
- `test -f web/screenshots/shell-route-fixture.png && test -f web/screenshots/shell-route-alt.png && test -f web/screenshots/shell-route-pending.png && test -f web/screenshots/shell-route-dark.png && test -f web/screenshots/route-gallery-compare.png` exits 0
- `grep -q 'ARAHARAH-ROUTE' web/scripts/capture.mjs` exits 0 AND `grep -q 'fixpair' web/index.html` exits 0
- `grep -q 'ROUTE_PROFILE_HARNESS_ONLY' web/src/mock/route-profiles.ts` exits 0
- `grep -q 'getPairOrigin' web/src/mock/route-profiles.ts` exits 0 (the pinned-corpus-only origin law)
- the runner-page profile comment lists the fixpair knob: `grep -q '?fixpair' web/index.html` exits 0
</acceptance_criteria>
<done>Five route capture legs pass against the production-shaped harness bundle — drawn, alt-swapped, pending, dark, and the DSGN-02 gallery artifact — all raced on state tokens with DOM text assertions.</done>
</task>
<task type="auto" tdd="true">
<name>task 3: Worker-side no-route-proxy gates (D-05)</name>
<files>bot/test/route-privacy.test.mjs</files>
<read_first>
- `bot/test/privacy.test.mjs` (the bot-side grep-gate style: comment stripping, banned-token scans, named cases)
- `bot/src` tree (the scanned surface)
- `.planning/phases/05-routing-route-display/05-CONTEXT.md` D-05 (the law being gated)
</read_first>
<action>
**`bot/test/route-privacy.test.mjs`** — RED-first-then-green (pure gates over the unmodified Worker tree):
- `RP1 no route proxy` — strip comments from every `bot/src/**/*.ts` (the privacy.test.mjs pattern) and assert ZERO occurrences of `/api/route` and `route` handler registrations matching `/app\.route\(|on\("message:url"|\.route\("route/`… keep it PRECISE: assert zero `/api/route` tokens AND zero `registerRoute|routeProxy` tokens (D-05: the Worker gains no route endpoint this phase).
- `RP2 no routing import` — zero `@araharah/routing` specifiers anywhere in `bot/src` (routing lives in the webview bundle only; the Worker imports only `@araharah/search`).
Header comment: the D-05 citation + "the pivot's Worker boundary, gated".
**Acceptance:** `npm test -w bot` exits 0 with `PASS RP1` and `PASS RP2` (all existing bot suites + GE gates still green).
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- `npm test -w bot` exits 0 and the output contains `PASS RP1` and `PASS RP2`
- `grep -rc '/api/route' bot/src --include='*.ts' | grep -v ':0' | wc -l | tr -d ' '` returns 0 (the gate's premise holds in the tree itself)
- `grep -rc '@araharah/routing' bot/src --include='*.ts' | grep -v ':0' | wc -l | tr -d ' '` returns 0
</acceptance_criteria>
<done>The Worker's routing abstinence is mechanically gated: no route endpoint, no routing-workspace import — the pivot's D-05 boundary holds by test, not by memory.</done>
</task>
</tasks>
<verification>
1. `npm run build -w theme && npm run build -w web && npm test -w web` — BA1–BA10 all green (BA9 prints the measurement).
2. `npm run web:capture` — CAPTURE OK — 13/13 (the five new legs non-blank, state-raced, DOM-asserted).
3. `npm test -w bot` — all suites green including GE1–GE7 and RP1/RP2.
4. Full suite: `npm run routing:test && npm run routing:corpus && npm test -w web && npm test -w bot` — green.
5. The amended SC3 evidence chain: RT1–RT10 (corpus routes cleanly) + BA9/BA10 (bundle-size gate) + the 05-01 C2 type gate + the pending capture (deliberate, designed) — all four clauses have a green instrument.
</verification>
<success_criteria>
- BA9/BA10 pass over the production build with the measurement printed.
- 13/13 captures pass including the five route legs driven by ?fixpair.
- RP1/RP2 gate the Worker out of the routing path.
- The phase's full verification story is green across all four workspaces.
</success_criteria>