Group handed back the raw zip router, so `g := app.Group(p); g.Use(mw)` walked
past every gate in this file and hung middleware on a node whose subtree is
necessarily empty — typed ops register through ZipApp, on the ROOT, so a
subsystem's routes never land beneath the group. zip v1.24 refuses to compose
that, correctly, and crash-looped fifteen plugins on v1.801.425/.426.
This file's own header said that idiom "needs no policing: the group already
bounds it". It bounds the MIDDLEWARE and says nothing about where the ROUTES
went, which is the half that mattered.
Group now returns a child scope, so all three idioms are ONE install — at the
root, gated by path — and no node is left that can be empty. The child carries
`at`, its path prefix, because a group PREFIXES what is registered through it:
without that, `zip.Get(app.Group("/v1"), "/bots", h)` (bots, entitlements)
registers /bots — a route silently MOVED, which still composes, so no compose
check could have caught it. OpScope carries the same prefix for the same reason.
Also, three subsystems that were escaping their bounds silently, because a bare
Group used to skip the check entirely:
- team answers /collaborator; the manifest says so and the plugin did not.
- label answers /v1/risk/labels; same.
- zt installed one bridge at /v1/mesh, a level ABOVE the only path it
serves there. One app.Use, gated by scope to what zt declares.
And OwnsHealth on authz/domain/experiments/metrics, which serve their own
health — so serve.go's generic liveness route was a second declaration of it.
Verified three ways, because a weaker check let a broken build reach production
twice today. The binaries were exiting on `mkdir /var/lib/cloud/orgs: permission
denied` BEFORE composing, and I read that silence as a pass:
1. survival — a compose panic is fatal, so rc 124 under timeout is the only
honest signal; "zip new" is logged before composition and proves nothing.
17/17 affected plugins survive, each with private ports and a writable dir.
2. route projection — `describe` diffed before vs after across 34 plugins:
34 identical, 0 changed. deploy/label/referrals now project where they
previously panicked. This is what caught the moved-route defect above.
3. go test -run 'Scope|Mount|Prefix|Route' green.
o11y is NOT fixed here: its three routes (logs, metrics, query_range) are
duplicate declarations against upstream o11y@v1.5.55, and zip dedupes on the
resolved path, so moving the node cannot help. Separate change.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>