docs(api): state what projectResolution: 'required' serves for package routes, and pin the ruled mount shape - #17666
Conversation
…ctResolution 'required' The escalation condition on this card asks whether the unconditionally mounted unscoped `/packages*` crosses an environment boundary on a `required` host. It does not, and this is the measurement. The unscoped mount supplies neither of `urlEnvironmentId`'s two sources, so the request names no environment of its own and is bound by the host resolver's documented order 2-6; the scoped URL is order 1, the stronger addressing primitive. Both mounts are the same handler behind the same `dispatch()` preamble, so the tenancy gate answers them identically: a non-member is refused `PROJECT_MEMBERSHIP_REQUIRED` before the domain on either, and a member reaches one byte-identical door. The fixture gives the two environments data planes that answer differently (registry-bearing vs registry-less), so the response itself names which environment served it -- and the positive control drives the same door, the same assertions and the same spies for a request the host DOES bind to another environment, so the no-leak legs are readings rather than a probe that could not have seen a crossing. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e ruled mount shape `environment-routing.mdx` said `projectResolution: 'required'` registers only environment-scoped routes for data/meta/AI/automation/package handlers. Three of those four families are true of the dispatcher, and the REST `POST /packages/publish` registrar honours it too, but the dispatcher's `/packages*` bridge is mounted unscoped unconditionally and is ruled to stay that way -- the mount site records why, and a doc that over-claims is what set the operator's mental model. So the doc now states what the code does, with the isolation half stated beside it: an unscoped request names no environment, so it is resolved by the same order 2-6 and passes the same membership and capability gates as a scoped URL, and what `required` does not deliver for package routes is the URL guarantee rather than isolation. The mount pin turns the comment's "stays that way" into something a future tidy-up trips over. Reverse-verified: moving the unscoped call into the `required` branch reds exactly the three unscoped package cases (3 failed / 11 passed) while the scoped cases, the dropped-sibling controls and the binding measurement stay green; restore reproduces the HEAD blob byte-for-byte. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
|
ACCEPT — Enqueue resistance read as every check, not only the required ones: What this seat verified rather than accepted on the PR body's word:
Two honest limits, recorded so nobody reads this as more than it is
Landing verification is owed after the queue merges, not now: single-parent squash (
Generated by Claude Code |
Fixes #17432
The card's escalation condition had to be measured before either side of the doc/code disagreement could be called wrong, because the sentence under test asserts an isolation property. It was measured first, and it does not fire: an unscoped
/packages*underprojectResolution: 'required'reaches no other environment's package data. So the remedy is the documented one — the doc is the drift — plus a pin that holds the ruled mount shape.Zone 1 — the measurement, and its positive control
New file
packages/runtime/src/packages-unscoped-environment-binding.test.tsdrives the two real mounts (both calldispatcher.dispatch()with the same pre-stripped/packagessubpath; the scoped one carries:environmentIdonreq.params) against a two-environment host with aKernelResolver.What it establishes:
urlEnvironmentIdhas exactly two sources inprepareResolverHints— an/environments/:idpath segment andreq.params.environmentId— and the unscoped mount supplies neither, so the request is bound by the host's documented resolution order 2-6. The scoped URL is order 1, i.e. the stronger way to address an environment, not a weaker one. Control for thatundefined: the same field is populated the moment the caller does name an environment.hostasked forobjectql; both tenant kernels asked 0 times, 0 package rows read).403 PROJECT_MEMBERSHIP_REQUIREDbefore the domain on the unscoped path and on the scoped path alike, withsys_environment_memberqueried once per request and no package row served; a member reaches one byte-identical door through either.env_beta's data plane when the host's resolver does bind the request there. The fixture gives the two environments data planes that answer differently — registry-bearing versus registry-less — so which environment answered is readable off the response itself (403 PERMISSION_DENIEDfrom the ADR-0106 D4 capability gate inside the domain, versus503 Package service not availablefrom the registry-less host default), not only off a spy. Had the no-leak legs been vacuous, this leg could not have separated them.⇒ What
requireddoes not deliver for package routes is the URL guarantee ("every caller has named an environment in the path"), not isolation. That is a documentation defect, not a tenancy one.What is NOT changed, and why
registerPackageRoutes(prefix)stays where it is. The mount site rules the asymmetry explicitly, verbatim:Moving that call into the
requiredbranch is the removal the comment refuses, so the option the card lists first is not taken here. No runtime file is edited at all.The doc correction
content/docs/api/environment-routing.mdxsaidrequired"Registers only environment-scoped routes for data/meta/AI/automation/package handlers". Three of the four dispatcher families are true of it, and@objectstack/rest's own package registrar honours it too —direct-mount-composition.tsmountsPOST {base}/packages/publishat the scoped base only whenprojectResolution === 'required'— but the dispatcher's/packages*bridge is the exception. The row now says that, and a Callout beside it states both halves: that the unscoped package surface stays open on arequiredhost (destructive lifecycle verbs included), and that this is not an isolation hole, with the reason.Lane note:
content/docs/**isdomain:devx. This is a one-line table correction plus the note that makes it readable, on the page that documents the dispatcher's own posture — not a docs restructuring.The pin
packages/runtime/src/dispatcher-plugin.required-scoping-mounts.integration.test.tsboots theplugin-hono-server+ dispatcher composition underrequiredover a real socket and holds the ruled shape:/packages*still served unscoped, the scoped door served too, and — the second direction, which is what makes a 404 a reading here — the automation and action families' unscoped mounts genuinely gone while their scoped ones answer. Discriminator reused fromdispatcher-plugin.scoped-packages-door.integration.test.ts: arrival at the anonymous-deny floor, a verdict no transport sink emits. The/aifamily is deliberately excluded from the dropped-mount cases — its dynamic routes arrive through theai:routeshook this composition never fires, so an/ai404 here would be about the absent service plugin rather than aboutprojectResolution.Reverse verification. Mutating
dispatcher-plugin.tsso the unscoped mount is skipped underrequired— the exact forbidden tidy-up — reds only the three unscoped package cases (3 failed / 11 passed; the scoped cases, the dropped-sibling controls and the Zone-1 measurement stay green), with the failure text naming the mount-site comment. The mutation was proved on disk (anchor occurrences 1 to 0, injected text 1, blob hash moved) before the run, and the restore leg reproduced the HEAD blob byte-for-byte (git diff HEADempty) and returns 14/14 green. No build/dist step is involved: both test files import./dispatcher-plugin.jsrelatively, so the mutation is read from source.Evidence
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths passed; the tool derives its own change set): 80 families. Reconciled with--ran: 80 derived, 78 run, 2 NOT MEASURED, 0 UNRUN.pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debt, both exit 3 / PREREQUISITE NOT MET — they read every package's builtdist/(37 packages unbuilt in this worktree) and their own text says exit 3 is neither a pass nor a finding. That prerequisite is CI'sBuild Core.pnpm --filter @objectstack/spec check:skill-exampleshit the same class at first (exit 1, "client-react/dist holds no .d.ts"); building@objectstack/client+@objectstack/client-reactturned it into a real reading: exit 0, 258 prose examples type-check across 3 surfaces.pnpm lint(eslint . --no-inline-config, whole repo, not narrowed): exit 0.pnpm --filter @objectstack/runtime typecheck: exit 0 (tsc --noEmitpluscheck:test-typecheck: "OK — @objectstack/runtime's test layer compiles under packages/runtime/tsconfig.test.json"). Its first run was red with 4 TS18048 in the new test file; fixed at the source, never ledgered.pnpm --filter @objectstack/runtime test(the wholelocalproject): exit 0 — 256 files / 3588 tests passed.scripts/pm/os-verify-lock.shon slotissue-17432-dev; every verdict line read from the wrapper's ownVERDICT command-exit, never a bare$?.No changeset — measured, not assumed
skip-changeset, decided explicitly: nothing published moves.@objectstack/runtimepublishes["dist","README.md","CHANGELOG.md"]; grepping that surface after a real build for symbols unique to the two new test files returns 0 files each, with positive controls in the same grep returning 6 / 2 / 3 files (createDispatcherPlugin,registerPackageRoutes,PROJECT_MEMBERSHIP_REQUIRED).content/docs/**is consumed byapps/docs, which isprivate: true.pnpm check:published-filesis green.Acceptance notes
Noted, not filed (neither is a reproducible defect, a contract violation, or a metadata-authoring trap):
environment-routing.mdxlistsdata,meta,automationandaibut notpackages, although the scoped package door has existed since finding(runtime): scoped/api/v1/environments/:id/packages[/:id]has no dispatcher door on aplugin-hono-server-only composition after #16628 (B′ follow-up to #14503) #16781. It reads as illustrative rather than exhaustive, and the page's owndescriptiondoes name package calls, so it is not a false claim — just a list that could carry one more line. Successor: the nextdomain:devxpass over this page.direct-mount-composition.ts'srequiredbehaviour (scoped base only forPOST /packages/publish); the claim above is a direct read of that file.packages/restis outside this card's file surface, so nothing was added there.Scope
packages/runtime/src/domains/packages.ts(held by #17416) andpackages/client/src/index.ts(serial behind #17454) were read but not edited; no file outsidecontent/docs/api/environment-routing.mdxand the two new test files is touched.Generated by Claude Code