diff --git a/docs/content/docs/architecture/API-ROUTE-LAYOUT.md b/docs/content/docs/architecture/API-ROUTE-LAYOUT.md
index b8611ab..d736b8f 100644
--- a/docs/content/docs/architecture/API-ROUTE-LAYOUT.md
+++ b/docs/content/docs/architecture/API-ROUTE-LAYOUT.md
@@ -42,11 +42,25 @@ Two refinements that matter in practice:
- **"Must stay" is not one thing.** (1a) is immovable; (1b) is movable but
coordinated. Do not lump OAuth callbacks in with firmware paths.
-- **There is a merge-collision bucket.** `/`, `/health`, and `/ws` are defined
- in *both* routers today and must be reconciled when the binaries merge. TuneIn
- is **not** in this bucket: `/bmx/tunein/*` (speaker <-> BMX integration, frozen)
- and `/api/tunein/*` (the player's generalized radio search/play, ours to
- change) are two different layers, not a collision.
+- **The merge-overlap bucket is smaller than it looks.** Verified against the
+ two routers, only **`/` is a true collision** (service `HandleRoot` vs the web
+ app's `serveIndex`); resolve it with a small **landing page** at `/` that lets
+ the user pick Admin/Setup (service) or the App (web). **`/health` is a merge,
+ not a clash** (both define it; standardise on the service's richer body, which
+ carries version + timestamp, and confirm nothing depends on the web's
+ `{"status":"ok","version"}` shape). **`/ws` and `/static/*` do not collide at
+ all** — the service registers neither, so bringing the web's in is purely
+ additive. TuneIn is **not** in this bucket either: `/bmx/tunein/*` (speaker <->
+ BMX integration, frozen) and `/api/tunein/*` (the player's generalized radio
+ search/play, ours to change) are two different layers.
+
+ **Resolve overlaps structurally, before merging, not behind a flag.** A
+ conditional "only register the web routes when opt-in is on" does not fix a
+ collision — it just hides it while the flag is off, and the double-registration
+ returns when it's on. Do not rely on chi to detect or warn about it. Clean up
+ `/` (and the `/health` merge) up front so the merged router is unambiguous
+ regardless of the flag. The opt-in (below) exists only to let people optionally
+ run the merged variant and give feedback, not as a collision guard.
## What pins the frozen routes (evidence)
@@ -94,11 +108,11 @@ Grouped by prefix. The authoritative enumerated list is the router golden file
Defined in `pkg/service/soundtouchweb/mount.go`. Not currently mounted inside
the service; it is a separate binary.
-| Group | Category | Note |
-|------------------------------------------------------------------------------------------|-----------------|--------------------------------------------------------------------------------------|
-| `/api/*` (devices, control, tunein, zone, radiobrowser, play-url, device-speak) | (3) web/control | Freely restructurable |
-| `/health`
`/static/*`
`/ws` | (Infra) | `/health` and `/ws` collide with the service at merge time |
-| `/`
`/device/*`
`/devices`
`/playurl`
`/radiobrowser`
`/tts`
`/tunein` | (4) frontend | The anti-pattern: each SPA route enumerated in the backend, all serving `index.html` |
+| Group | Category | Note |
+|------------------------------------------------------------------------------------------|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `/api/*` (devices, control, tunein, zone, radiobrowser, play-url, device-speak) | (3) web/control | Freely restructurable |
+| `/health`
`/static/*`
`/ws` | (Infra) | `/health` is a merge (standardise on the service's body); `/static/*` and `/ws` are additive (the service registers neither) |
+| `/`
`/device/*`
`/devices`
`/playurl`
`/radiobrowser`
`/tts`
`/tunein` | (4) frontend | `/` is the one true collision (-> landing page); the rest move under `/app/*`. The anti-pattern: each SPA route enumerated in the backend, all serving `index.html` |
## Deployment scenarios, reachability, and trust boundaries
@@ -509,20 +523,48 @@ them.
step is about our movable routes only.
- **Exclude** `/mgmt/spotify/callback` and `/mgmt/amazon/callback` (1b):
freeze, or move only with a deliberate provider re-registration.
-2. **Fold `soundtouch-web` into the service.** Bring the control API in as
- `/api/control/*` and the UI under `/app/*` (one role-gated app, see above).
- Reconcile the `/`, `/health`, `/ws` collisions (the web socket as e.g.
- `/api/control/ws` or one shared `/ws`; the service keeps `/` and `/health`).
+2. **First, migrate `soundtouch-web` in place to the target API shape.** Before
+ touching the service, restructure the standalone `-web` binary's own routes to
+ what they should be *after* the merge: the control API under `/api/control/*`
+ and the SPA under `/app/*` (with `/ws` as e.g. `/api/control/ws`). Unlike the
+ service, this is a **direct migration, not a dual-mount, and with no
+ deprecation signal**: `-web`'s only client is its own bundled frontend, served
+ and reloaded from the same binary, so there are no out-of-band callers to keep
+ compatible — restructure the routes and update the frontend in lockstep, in
+ small commits, and a stale tab is fixed by a reload. (The careful
+ add-alias-then-deprecate dance is reserved for `-service`, which is central and
+ serves callers we do not control.) The payoff: by the time we merge, `-web`'s
+ routes already match the target and don't overlap the service's namespaces, so
+ the merge below is a near-additive mount.
+
+3. **Fold `soundtouch-web` into the service.** Bring the (already target-shaped)
+ control API in as `/api/control/*` and the UI under `/app/*` (one role-gated
+ app, see above).
+ The actual overlap to clean up (verified) is small: only **`/`** truly
+ collides, so replace the two competing root handlers with a **landing page**
+ that routes the user to Admin/Setup or the App; **`/health`** is a merge
+ (keep the service's richer body); **`/ws`** and **`/static/*`** are additive
+ (the service registers neither, so no collision). Do this cleanup
+ structurally and verify it (a test that builds the merged router and asserts
+ no double-registration) rather than hiding overlaps behind the opt-in flag.
Keep the two TuneIn layers separate (frozen `/bmx/tunein/*` vs the player's
- `/api/control/*` radio feature). Add the landing page / authority boundary at
- `/`.
-3. **Deprecate the `soundtouch-web` binary.** It keeps working in 0.x but prints
+ `/api/control/*` radio feature).
+ - **Ship the merged variant behind an opt-in flag (default off).** Its sole
+ purpose is to let people optionally run the combined binary and give
+ feedback; it is **not** a collision guard and **not** a security boundary on
+ its own. Until the auth track lands, default-off keeps the merged app/control
+ surface from being exposed unless an operator deliberately enables it. The
+ flag follows the same CLI/env/persisted precedence as `server-url`, and is
+ the seam the `deployment-mode` parameter later subsumes.
+4. **Deprecate the `soundtouch-web` binary.** It keeps working in 0.x but prints
a startup deprecation warning (along the lines of "this binary is removed in
1.x, use soundtouch-service") so its removal is no surprise.
-4. **Warn on old-route hits in the service, observably.** When a deprecated path
+5. **Warn on old-route hits in the service, observably.** When a deprecated path
is called, log a deprecation warning **and** count it (a metric / signal), so
the 1.x removal is data-driven: a route is only cut once it has gone quiet
- across real deployments, not on a guess.
+ across real deployments, not on a guess. *(Done for the `/setup` and `/mgmt`
+ legacy paths via `DeprecatedRouteMiddleware`; extends to any future aliased
+ route.)*
### Auth track (0.x, parallel)