fix(runtime): domain claims and the membership skip list stop at a segment boundary - #16842
Conversation
…gment boundary
`DomainRoute.match` defaulted to `'prefix'` — a bare `path.startsWith(prefix)`
with no segment boundary — and ten shipped routes carried that implicit
default, so `/datax`, `/metaxyz`, `/uifoo`, `/aixx` and `/packagesomething`
were each claimed by a domain that does not own them. `/auth` was the
eleventh member of the family and was repaired on its own; this closes the
rest at the seam so the next domain added is boundary-correct by default.
The default is `'segment'` now. `match: 'prefix'` still buys the legacy bare
`startsWith` claim for a route that asks for it in writing, and the three
`?`-suffixed routes (`/keys?`, `/mcp?`, `/mcp/skill?`) declare it — a prefix
ending in `'?'` has no `/` after it, so a segment match cannot express the
query-string form at all.
`enforceProjectMembership`'s control-plane skip list carried the same
predicate with a worse consequence: a claim that is too wide sends traffic
somewhere wrong, a skip list that is too wide sends traffic past a check.
`/authentication/foo` satisfied `startsWith('/auth')` and was waved past the
membership check. Its boundary is `'/'`, `'?'` or end-of-string, so
`/auth?redirect=...` keeps the exemption it has today.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin feaea9374744b08687d12dd1f4b61b5058459a21 && git checkout feaea9374744b08687d12dd1f4b61b5058459a21
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin acf4d38336ac44b256d935a0965adc892d9c9be9 b15e44d2d579d16c2e95c3be26d7bbba5972f9d7 && git checkout -B drift-repro acf4d38336ac44b256d935a0965adc892d9c9be9 && git merge --no-ff b15e44d2d579d16c2e95c3be26d7bbba5972f9d7
node scripts/docs-audit/affected-docs.mjs --json acf4d38336ac44b256d935a0965adc892d9c9be9
|
Accepted by the
|
| paths | answer before |
|---|---|
/actionsx /aixx /automationx /metaxyz /packagesomething |
401 |
/i18nxx /notificationsx |
501 |
/analyticsx /uifoo |
fell through, handled=false |
⭐ GET /datax |
200 SUCCESS envelope for a fabricated object — the handler reads substring(5) as an object name |
⭐ GET /datax/foo |
threw Record foo not found in x |
All eleven now answer ROUTE_NOT_FOUND; every canonical prefix and sub-path unchanged. ⇒ the card's ⛔ "measured per domain, not assumed" is exactly what caught the /data case, and a rider on #16026 would have missed it.
Stop-and-report condition: checked and NOT triggered — no in-repo caller concatenates a domain prefix without a separator, no route-ledger row names the shape, no SDK method addresses one.
⭐ The sweep closed a class, not a row
Step 1 found 11 startsWith-shaped path predicates; exactly 2 are boundary-free prefix predicates on a request path (DomainHandlerRegistry.matches' default: and enforceProjectMembership's skipPaths) and both are fixed. The sixth site was repaired first and separately, as ranked: skipPaths now stops at /, ? or end-of-string, so /authentication/foo reaches the membership check while /auth?redirect=… keeps today's exemption.
⭐ And the sweep produced a second finding: #16839 — isAuthGateAllowlisted exempts by unanchored substring/suffix, so an object literally named auth (/data/auth/123) or a record whose id is health (/data/x/health) is exempted from the ADR-0069 auth-policy gate. Filed bare (no labels — grading is triage's). ⇒ that finding exists only because the card refused "fix the six and close".
Gates
- CI: 39 raw check runs → 33 after latest-per-name collapse: 30 success, 3 skipped, 0 red, 0 pending. Legacy statuses read separately (the check-suite signal does not cover them): combined
success(1 context,Vercel). - Clause ②:
--pair 16842exit 0; both declaration documents read independently (⛔ the gate's 「both carriers agree」 is about the label carriers — [finding] Clause ② is read from two different documents by two gates with nothing joining them — a card declaringnoand a PR body declaringyespasses both, and--pair's green line says 「both carriers agree」 about the LABELS, not the declarations #16770): PR body →no, card claim5582384387→no. Narrowing only: every change removes a claim a domain does not own.
⛔ The hard serial was respected, not routed around
packages/runtime/src/domains/automation.ts is absent from this diff and still held by #16755. Accept-time re-scan, 25 open PRs, FULL pagination, 0 empty file lists, 682 distinct files: all nine files this PR touches have 0 other holders. ⭐ Control fired: scripts/engine-double-contract.pinned.json 4 holders, packages/metadata-protocol/src/seed-loader.ts 2.
⭐ And the route chosen was argued on its merits, not as a way past the fence: the default flip closes the whole class (domain eleven is boundary-correct without anyone remembering) and fixes /automation without editing the held file.
packages/runtime/src/domains/auth.ts was outside the file surface I declared, and the fix needed it. It re-derived the holder measurement on its own base before editing (22 open PRs, full pagination, 0 empty lists, positive control fired → 0 holders) and reported it. ⭐ That is the correct response to a fence that does not cover the work — ⛔ neither skipping the file nor editing it silently.
Housekeeping the card asked for, done
- The registry header comment asserting the rough edges are "explicitly not this seam's job" was replaced — ⛔ not left stranded asserting the opposite of the code.
auth.ts's two now-false sentences corrected (docblock-only — every changed line in that file is a comment).- The registry test's rough-edge assertion was INVERTED rather than deleted — ⭐ the overshoot control survives as a control.
Docs drift — the provenance caveat was LIVE this time, and it was tested
⭐ On the previous PR this lane found git diff --stat <base> <bot merge tree> -- content/docs empty, so the "different tree" warning did not apply. Here it is NOT empty (api/data-api.mdx, plugins/anatomy.mdx, references/api/protocol.mdx — other people's landed work), so the delivery read the differing page too rather than reusing last round's conclusion. ⇒ the caveat is checked per PR, not remembered.
- ⛔
content/docs/releases/v17.mdx— read, not edited. ItsenforceProjectMembershipentry is the already-fixed dispatcher 多个 domain 调用契约里没有的方法 —— #4087 的同类,只是方向相反(契约缺声明,不是调用点乱编) #4127 defect and says nothing about which paths the skip list exempts ⇒ shipped release notes are not describing the old behaviour, and there is nothing for this seat to file. That closes the question I raised. content/docs/api/environment-routing.mdxnames the symbol only as anapiconfig key surviving the boot merge, ⛔ not as a path rule ⇒ unaffected.- Blind-spot hand-read over
content/in short wrap-immune tokens, both rules: zero hits for the claim shapes; everystartsWithhit is the ObjectQL/formula/RLS operator, never a routing rule. The one input-shaped page surfaced (references/api/dispatcher.mdx) is not falsified for two independent reasons — it is marked AUTO-GENERATED — DO NOT EDIT, and its source ispackages/spec's declarativeDispatcherConfig.routes[].prefix, a different schema, which this PR does not touch. - ⛔ Nothing under
content/is in this diff.
Deferred, ⛔ not filed on the delivery's word
Three observations were declined with reasons (a dispatcher-plugin.ts mount-time startsWith that is not a request-path claim; the census undercount, which this PR itself answers; and the platform's duplicate-footer behaviour on body edits). ⛔ This seat has not independently re-derived them, so ⛔ none is filed here.
Landing: marked ready and routed to the merge queue. ⛔ Not merged outside the queue; ⛔ no governed surface in this diff.
Generated by Claude Code
Fixes #16263
Clause-②: no
Ten shipped dispatcher routes carried the implicit
match: 'prefix'default — a barepath.startsWith(prefix)with no segment boundary — so/datax,/metaxyz,/uifoo,/aixxand/packagesomethingwere each claimed by a domain that does not own them. Plus the sixth site the rider added: the membership skip list, which is the same predicate with a worse consequence.Step 1 — the sweep, before any fix
The population of
startsWith-shaped path predicates inhttp-dispatcher.tsand the adjacent dispatch paths, onorigin/main7f96e1417e. Located by symbol, never by line number.DomainHandlerRegistry.matchesdefault:path.startsWith(route.prefix)enforceProjectMembershipskipPathsskipPaths.some(p => path.startsWith(p))isAppEndpointPath(api-endpoint-step.ts)path.startsWith(mount) && path.length > mount.lengthappEndpointMountPrefixends in/, and its docblock says so ("so a path test cannot match a sibling like/api/v1/appsx")extractEnvironmentIdFromPathpath.includes('/cloud/environments/')includesis what it wantsresolveRequestScopeacceptOAuthAccessTokenenforceProjectMembershipshare-link carve-out$dispatcher-plugin.tstoScopedPathroutePath.startsWith(prefix)dispatcher-plugin.tsmountAiRouteroute.path.startsWith('/api/v1')isAuthGateAllowlistedALLOW_PREFIXESstartsWith('/auth/')+ exact fallback/isAuthGateAllowlisted/auth/testpath.includes('/auth/')isAuthGateAllowlistedALLOW_SUFFIXESendsWithx5Population: 11 sites. Boundary-free prefix predicates on a request path: exactly 2, and both are repaired here. The other nine are either boundary-correct by construction, not request claims, or a different predicate shape — that last pair became its own card rather than a rider, because a substring/suffix test in
packages/coreis a different mechanism and a different repair question.Census, re-derived on this base
The confirmed census holds — 10 routes on the bare default, 5 declaring
'segment'(besides/auth),/health+/readyon'exact'— with one addition it did not name:/keys?,/mcp/skill?and/mcp?also carry the implicit'prefix'default, deliberately. That is 20 registered routes, not 17, and those three are the reason the repair is not simply "make everything a segment".Step 2 — the sixth site, ahead of the ten
enforceProjectMembership's skip list held'/auth', so/authentication/foosatisfiedstartsWith('/auth')and was waved past the membership check. Repaired first, and graded as the rider asked: latent, not harmless. Nothing claims/authentication/*today, so such a request 404s before the missing check matters — and it goes live the day any domain claims a path of that shape.The boundary is
'/','?'or end-of-string. The'?'is load-bearing, not decorative:cleanPathhas only had a trailing slash stripped at that point, so an adapter passing the query through presents/auth?redirect=..., which is skipped today and must stay skipped. A repair accepting only'/'would newly gate the control plane on membership — a wider change than the card asks for, in the dangerous direction.Step 3 — the ten, each measured
Taken through
HttpDispatcher.dispatch()on the fixture in the new pin,GETunless noted. ⛔ Not inferred from/auth:/actionsx401 UNAUTHENTICATED(claimed by/actions)404 ROUTE_NOT_FOUND/aixx401 UNAUTHENTICATED(claimed by/ai)404 ROUTE_NOT_FOUND/analyticsxhandled=false(claimed by/analytics)404 ROUTE_NOT_FOUND/automationx401 UNAUTHENTICATED(claimed by/automation)404 ROUTE_NOT_FOUND/datax200SUCCESS envelope (claimed by/data)404 ROUTE_NOT_FOUND/datax/fooRecord foo not found in x404 ROUTE_NOT_FOUND/i18nxx501 NOT_IMPLEMENTED(claimed by/i18n)404 ROUTE_NOT_FOUND/metaxyz401 UNAUTHENTICATED(claimed by/meta)404 ROUTE_NOT_FOUND/notificationsx501 NOT_IMPLEMENTED(claimed by/notifications)404 ROUTE_NOT_FOUND/packagesomething401 UNAUTHENTICATED(claimed by/packages)404 ROUTE_NOT_FOUND/uifoohandled=false(claimed by/ui)404 ROUTE_NOT_FOUNDThey were not uniform, which is exactly why per-domain measurement was owed.
/datais the worst and nothing about it was predictable from/auth's200 {}: its handler readsreq.path.substring(5)as an object name, so the stray characters became the name of an object nobody declared —GET /dataxanswered a success envelope for a fabricated object, andGET /datax/foothrewRecord foo not found in x, an unattributable 500 naming a record and an object the missing boundary manufactured.The stop-and-report condition: checked, NOT triggered
No live dependent was found for any of the eleven rows:
dispatcher.dispatch(...)call site indispatcher-plugin.tswrites the literal prefix plus a/-led sub-path (mountPackagesRouteand the analytics/i18n/mcp/keys/notifications mounts);route-ledger.tsrow names a shape of this kind;⇒ nothing here is a published capability being removed; it is a wrong answer being stopped, so
Clause-②: nostands as declared.The engineering choice, and the held file
The card offered two routes. I took the registry default flip, and it is not a way around the fence — it is the better repair on its own merits:
matchstill defaults to'prefix'and domain number eleven repeats the defect; nothing in the tree would say so./automationwithout editingpackages/runtime/src/domains/automation.ts, which is held by feat(runtime): give the two operator run-lifecycle verbs a door (cancel, restore-suspension) #16755. The per-domain route could not have delivered all ten without touching it, and nine-of-ten is not this card.I re-derived the holder measurement on my own base rather than inheriting it: 22 open PRs, full pagination, 0 empty file lists, 458 distinct files, positive control fired (
metadata-protocol/src/protocol.ts2 holders,scripts/engine-double-contract.pinned.json4).automation.tsheld by #16755, confirmed. Every file this PR touches has 0 holders,packages/runtime/src/domains/auth.tsincluded — which the dispatch fence did not cover, so I measured it before touching it.What the flip could have broken, and did not
A prefix ending in
'?'has no/after it, so'segment'cannot express it:/keys?,/mcp/skill?and/mcp?would have gone dark silently. They declarematch: 'prefix'now and are pinned.match: 'prefix'is not deprecated — it is the legacy claim, available by declaration instead of by default.Landing shape — copied from #16026
matchdeclarations plus pins on the narrowed rows and pins on the overshoot controls. Every domain must still claim itself and a sub-path under it,/auth/me/permissionsincluded (plugin-auth 的终结式 catch-all 吞掉 /api/v1/auth/* 下别人的路由 —— console 权限层目前靠 kernel.use() 顺序才活着 #4088), and the?routes must still resolve to their own entries. A pin asserting only that/datax404s would pass a "repair" that deleted the/datadomain outright.packages/runtime/src/domains/auth.ts's docblock reasoning is reused, not reinvented — and its two now-false sentences ("spelled out rather than left to the default", "defaults to'prefix'") are corrected in the same PR rather than left asserting the opposite of the code.domain-handler-registry.test.ts's rough-edge assertion is inverted, not deleted — it pinned the defect on purpose, so the sibling namespace must be provably released.Verification
pnpm --filter @objectstack/runtime testVERDICT command-exit 0pnpm --filter @objectstack/runtime typecheckVERDICT command-exit 0(tsc --noEmit+check:test-typecheckOK)pnpm --filter '@objectstack/runtime^...' buildVERDICT command-exit 0eslint . --no-inline-configscripts/pm/dispatch-gates.mjs)check:dual-build-cjs-loads,check:type-check-debtPREREQUISITE NOT MET, "⛔ This is NOT a pass: nothing was measured" — both need a fullpnpm buildclosure. NOT MEASURED, left to CIAblation — the pins can fail
Both defects were reinstated in the committed tree and the run went RED:
3 failed (3)test files, 30 failed / 90 passed. The mutation was proved on disk before the run (registry blob130fb34ato25fc93ce, dispatcher4aa3b054to631fd06f) and both files restored under anEXIT INT TERMtrap, verified by state rather than exit code —git diff HEADempty and both blobs byte-identical toHEADagain.Docs-drift hand-read
Result: nothing this diff falsifies. What was read, so the result is checkable:
Provenance caveat is LIVE here, and I am not claiming otherwise. The bot computed on merge commit
feaea9374744b08687d12dd1f4b61b5058459a21, not my head, and reported uncommitted changes in that checkout.git diff --stat 7f96e1417e feaea9374744b -- content/docsis NOT empty —api/data-api.mdx,plugins/anatomy.mdxandreferences/api/protocol.mdxdiffer (other people's landed work; my diff touches no docs). So unlike the previous run on this lane I cannot retire the caveat; I read the differingdata-api.mdxtoo, and it is unaffected (its only routing sentence is that these endpoints answer404 ROUTE_NOT_FOUNDon deployments without the service — a different cause from a lexical extension). This checkout is also shallow, so no ancestry claim is made anywhere in this reading; the comparison above is a tree diff between two present objects, which is exact.1.
content/docs/releases/v17.mdx— read, ⛔ not edited. ItsenforceProjectMembershipentry ("The project-membership gate never ran", #4127) is about a different, already-fixed defect: the gate probed the auth service through a shape it never had, souserIdstayed unset. It makes no statement about which paths the skip list exempts, so my change does not falsify it. Its other/authentries describe adapter route registration and the 501-on-empty-slot bridge, all still true. ⇒ nothing to report to release ownership.2.
content/docs/api/environment-routing.mdx— read in full, not skimmed. It namesenforceProjectMembershipexactly once, as an example of an authoredapiconfig key that survives the boot merge untouched. It makes no claim about which paths bypass membership enforcement. ⇒ unaffected.3.
packages/runtime/src/domains/auth.tscoverage hole — answered by the diff itself. The bot yielded no anchor for it, so this hand-read is its only coverage. My change to that file is docblock-only — proved mechanically: every changed line ingit diff 7f96e1417e HEAD -- packages/runtime/src/domains/auth.tsis a comment line.match: 'segment'stays on the route and the claim is byte-identical, so no page describing/authcan be falsified by it. Read anyway:api/plugin-endpoints.mdx("mounted under the auth plugin's/authprefix"),api/declarative-endpoints.mdx,permissions/authentication.mdx:1248(the/authdomain bridging tohandleRequest()and answering 501 on an empty slot) — all still true.4. The bot's permanent blind spot — a page stating a rule by its INPUTS. Searched
content/in short wrap-immune tokens, both rules:datax,metaxyz,uifoo,aixx,packagesomething,i18nxx,authx: zero hits.startsWith: hits are all the ObjectQL/formula/RLS operator, never a routing rule. The domain prefixes and phrases like "paths starting with" / "any path under" / "prefix match" surfaced exactly one routing page, below./authentication: every hit is a docs-site link (/docs/permissions/authentication), never an HTTP path.membershipco-occurring withskip/bypass/exempt: nothing about path prefixes (deployment/tenancy-modes.mdxisOS_SKIP_MEMBERSHIP_BACKFILL, unrelated).The one page that shape found:
content/docs/references/api/dispatcher.mdx, which says the dispatcher routes "based on URL prefix matching" and names no symbol in my diff — exactly the blind-spot shape. Not falsified, for two independent reasons: it is markedAUTO-GENERATED — DO NOT EDIT(regenerated bybuild-docs.ts), and its source ispackages/spec/src/api/dispatcher.zod.ts— the spec's declarativeDispatcherConfig.routes[].prefix, a different schema fromDomainHandlerRegistry. This PR touches no file inpackages/spec, so the generated page's inputs are unchanged.验收备注
PREREQUISITE NOT MET … nothing was measured,需要整仓pnpm build闭包,归 CI。HttpDispatcher.registerDomainHandler是外部包的注册入口,省略match的第三方路由同样会被收窄。方向是收窄(移除域并不拥有的认领),changeset 已声明为minor。isAuthGateAllowlistedexempts by UNANCHORED substring/suffix — an object namedauthor a record with idhealthbypasses the ADR-0069 auth-policy gate #16839 是同一次扫出来的另一个谓词形状(substring/suffix,在packages/core),⛔ 未在本 PR 修:机制不同、包不同、修法是另一个问题。/keys?·/mcp/skill?·/mcp?),它们是刻意的裸前缀,已就地声明match: 'prefix'。packages/runtime/src/domains/automation.ts全程未被编辑(由 PR feat(runtime): give the two operator run-lifecycle verbs a door (cancel, restore-suspension) #16755 持有);它由默认值翻转一并修好,无需触碰。🤖 Generated with Claude Code
https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Generated by Claude Code
Generated by Claude Code