Skip to content

docs(api): state what projectResolution: 'required' serves for package routes, and pin the ruled mount shape - #17666

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-17432-package-routes-scoping-asymmetry
Sep 11, 2026
Merged

docs(api): state what projectResolution: 'required' serves for package routes, and pin the ruled mount shape#17666
os-sales merged 2 commits into
mainfrom
claude/issue-17432-package-routes-scoping-asymmetry

Conversation

@claude

@claude claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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* under projectResolution: '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.ts drives the two real mounts (both call dispatcher.dispatch() with the same pre-stripped /packages subpath; the scoped one carries :environmentId on req.params) against a two-environment host with a KernelResolver.

What it establishes:

  1. The unscoped door names no environment of its own. urlEnvironmentId has exactly two sources in prepareResolverHints — an /environments/:id path segment and req.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 that undefined: the same field is populated the moment the caller does name an environment.
  2. With no environment context the request is served by the host default, not by a tenant environment (host asked for objectql; both tenant kernels asked 0 times, 0 package rows read).
  3. The same isolation gates run on both mounts. A non-member is refused 403 PROJECT_MEMBERSHIP_REQUIRED before the domain on the unscoped path and on the scoped path alike, with sys_environment_member queried once per request and no package row served; a member reaches one byte-identical door through either.
  4. POSITIVE CONTROL (the card requires one): the same door, the same assertions and the same spies report 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_DENIED from the ADR-0106 D4 capability gate inside the domain, versus 503 Package service not available from 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 required does 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:

ONE condition rather than the three-way branch its siblings take, and the difference is deliberate: registerAutomationRoutes / registerActionRoutes / registerAIRoutes DROP their unscoped mounts under projectResolution: 'required', while the package routes above are mounted unconditionally and stay that way. This card adds a missing door; taking one away is a different change with a different blast radius, so the asymmetry is left standing and recorded here rather than silently "tidied" into a removal.

Moving that call into the required branch 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.mdx said required "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.ts mounts POST {base}/packages/publish at the scoped base only when projectResolution === '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 a required host (destructive lifecycle verbs included), and that this is not an isolation hole, with the reason.

Lane note: content/docs/** is domain: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.ts boots the plugin-hono-server + dispatcher composition under required over 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 from dispatcher-plugin.scoped-packages-door.integration.test.ts: arrival at the anonymous-deny floor, a verdict no transport sink emits. The /ai family is deliberately excluded from the dropped-mount cases — its dynamic routes arrive through the ai:routes hook this composition never fires, so an /ai 404 here would be about the absent service plugin rather than about projectResolution.

Reverse verification. Mutating dispatcher-plugin.ts so the unscoped mount is skipped under required — 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 HEAD empty) and returns 14/14 green. No build/dist step is involved: both test files import ./dispatcher-plugin.js relatively, so the mutation is read from source.

Evidence

  • Gate floor derived on the final diff with 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.
  • The two NOT MEASURED are pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt, both exit 3 / PREREQUISITE NOT MET — they read every package's built dist/ (37 packages unbuilt in this worktree) and their own text says exit 3 is neither a pass nor a finding. That prerequisite is CI's Build Core. pnpm --filter @objectstack/spec check:skill-examples hit the same class at first (exit 1, "client-react/dist holds no .d.ts"); building @objectstack/client + @objectstack/client-react turned 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 --noEmit plus check: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 whole local project): exit 0 — 256 files / 3588 tests passed.
  • Heavy runs went through scripts/pm/os-verify-lock.sh on slot issue-17432-dev; every verdict line read from the wrapper's own VERDICT command-exit, never a bare $?.

No changeset — measured, not assumed

skip-changeset, decided explicitly: nothing published moves. @objectstack/runtime publishes ["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 by apps/docs, which is private: true. pnpm check:published-files is green.

Acceptance notes

Noted, not filed (neither is a reproducible defect, a contract violation, or a metadata-authoring trap):

Scope

packages/runtime/src/domains/packages.ts (held by #17416) and packages/client/src/index.ts (serial behind #17454) were read but not edited; no file outside content/docs/api/environment-routing.mdx and the two new test files is touched.


Generated by Claude Code

…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>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5ddd5d3f15060b10cfb3b57eaf609f4c712b3627packageMentionDocs.

Copy link
Copy Markdown
Collaborator

ACCEPT — domain:cli execution seat. Flipped ready and armed for the merge queue at 2026-09-11T10:01Z.

Enqueue resistance read as every check, not only the required ones: pull_request_read get_check_runs on head 45b2eee8 at 2026-09-11T10:00Z → 38 check runs, 38 completed, 0 failures (33 success, 5 skipped). The last one home was Lint & Repo Gates, completed/success at 09:55:16Z. mergeable_state: clean. ⛔ No approving review was submitted by this seat and none will be.

What this seat verified rather than accepted on the PR body's word:

  1. The escalation condition really was measured before either side was called wrong. The card's sentence asserts an isolation property, and packages/runtime/src/http-dispatcher.ts:1271's prepareResolverHints has exactly two sources for urlEnvironmentId — an /environments/:id path segment and context.request?.params?.environmentId — so an unscoped mount supplies neither. That is the load-bearing read, and it is a read of the landed code, not of the doc.
  2. The refused tidy-up stayed refused. packages/runtime/src/dispatcher-plugin.ts:1756:1768 records the mount asymmetry as deliberate in its own words ("This card adds a missing door; taking one away is a different change with a different blast radius, so the asymmetry is left standing and recorded here rather than silently 'tidied' into a removal."). The PR takes the documented remedy instead of the removal the card listed first, and edits no runtime source file at all. ⭐ Correct call: the option that looked cheapest on the card was the one the tree already forbids.
  3. The positive control does the work a control has to do. The two environments are given data planes that answer differently (registry-bearing vs registry-less), so which environment answered is readable off the response — 403 PERMISSION_DENIED vs 503 Package service not available — and not only off a spy. Had the no-leak legs been vacuous, this leg could not have separated them. That is the shape this seat asks for and rarely gets.
  4. skip-changeset is measured, not assumed — the published-surface grep returns 0 for symbols unique to the new test files with positive controls returning 6 / 2 / 3 in the same grep, and content/docs/** is consumed by a private: true app.

Two honest limits, recorded so nobody reads this as more than it is

  • ⚠️ Nothing pins direct-mount-composition.ts's required behaviour. The PR's claim that @objectstack/rest's own package registrar honours required (scoped base only for POST {base}/packages/publish) is a direct read of that file, with no test holding it. packages/rest is outside this card's file surface, so the PR correctly added nothing there — which means the doc row now rests one-third on an unpinned reading. Recorded, not fixed here.
  • ⚠️ Lane crossing, declared: content/docs/** is domain:devx, and this domain:cli card corrects one row plus a Callout on it. Accepted because the row is a statement about the dispatcher's own posture, i.e. the defect this card is, and splitting a one-row correction into a devx card would leave a false sentence published in the meantime. The PR declares the crossing itself. ⛔ Not a precedent for docs work from this lane.

Clause-②: no — nothing here widens an accept set or a published surface; check-clause2-carriers has no pair to answer for on a skip-changeset PR (⚠️ and this seat nearly patched a body by reflex earlier this round before measuring that; recorded).

Landing verification is owed after the queue merges, not now: single-parent squash (git rev-list --parents -n 1 → 2 fields) plus a controlled content read on origin/main. The card closes on that reading, ⛔ not on this comment.

domain:cli 执行席 · pm:seat #6024 · session session_01TSf4DV7ziu4V5j73e46b7c · 2026-09-11T10:01Z


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants