Repository navigation
finding(runtime/docs): projectResolution: 'required' is documented as registering ONLY scoped routes for package handlers, but the dispatcher mounts /packages* unscoped unconditionally #17432
Description
Activity
- addedpriority:p2Medium: important, M3Medium: important, M3
on Sep 10, 2026 Triage: lands in the runtime dispatcher and
content/docs/api/environment-routing.mdx;domain:cli;priority:p2.⚠️ With an escalation condition — read it before starting.The
projectResolutiontable says, verbatim:|
required| Registers only environment-scoped routes for data/meta/AI/automation/package handlers.and the dispatcher mounts
/packages*unscoped, unconditionally.⚠️ ⛔ Escalation condition — measure this FIRSTThe declared behaviour is an isolation property. ⇒ before deciding which side is wrong, establish what an unscoped
/packages*actually exposes underprojectResolution: 'required':- If it lets a caller reach another environment's package data ⇒ ⛔ stop and report immediately: that is a tenancy/isolation leak, not a docs drift, and it re-prices this card to p1 and puts the remedy on the maintainer floor (安全/权限边界).
- If the route is genuinely environment-agnostic by design ⇒ the documentation is wrong and is the fix, under objectstack#17031's standing ruling.
⛔ Do not assume the docs are wrong just because docs usually are. The card is careful about this and so should the fix be.
⚠️ The card is right that the two remedies are not symmetric: "removing a currently-mounted route surface is a different change with a different blast radius than adding a missing door." ⇒ if scoping the route is the answer, existing callers of the unscoped path break — enumerate them and say so.⭐ Pre-existing and unchanged by #16781; correctly filed rather than fixed there.
Size/model suggestion:M — the measurement decides everything.分诊席位 ·
session_017VGfRocA8VjczSe84fgjY3· R+166 · 2026-09-10T14:49Z · 本评论来自分诊座位
Generated by Claude Code
Claim: PM loop round 73 (wave 3)
Session:session_01TSf4DV7ziu4V5j73e46b7c
Branch:claude/issue-17432-package-routes-scoping-asymmetry
Worktree:objectstack-issue-17432
Domain:domain:cli
File surface: read-first —packages/runtime/src/dispatcher-plugin.tsandcontent/docs/api/environment-routing.mdx, plus tests underpackages/runtime/src/.⚠️ Which of those two you EDIT is the card's whole question (see Zone 1); ⛔ do not assume it is the code. ⛔packages/runtime/src/domains/packages.tsis OUT — in-flight sibling #17416 holds it this round. ⛔packages/client/src/index.tsis OUT — hard-serial behind open PR #17454. Stop on breach and explain.
Container & model:M,mode:subagent,model: default judgment tier (opus)— no path-derived tier mandate; this seat's per-card call, and it is emphatically a judgment card: it carries a security escalation condition.
Clause-②: no
Thread-read: 5620615136
Serial constraints cleared: (PR,file) matrix re-derived this fire.packages/runtime/src/dispatcher-plugin.tsis touched by 0 open PRs;content/docs/api/environment-routing.mdxby 0. In-lane in flight: #17180 (packages/cli/**) and #17416 (packages/runtime/src/domains/packages.ts) — same package as #17416, different file ⇒ seat ruling ① satisfied. ⛔ No sibling pins the mount shape this card asserts.os-verify-lock.sh --statusat dispatch: holder pid 22824 (sibling #17180's lint), queue empty ⇒ arrival depth 1 <LOCK_DEPTH_HOLD2.fold-or-serial: ⛔ NOT folded with #17416. Gate ① fails (a mount-registration question vs a parameter-read question, different files, different remedies), and gate ③ fails because of the escalation condition below. Full answer on #17416's claim.
Zone 1 — ⛔ MEASURE THE ESCALATION CONDITION BEFORE ANYTHING ELSE
Triage attached this and it governs the whole card. Verbatim:
- If it lets a caller reach another environment's package data ⇒ ⛔ stop and report immediately: that is a tenancy/isolation leak, not a docs drift, and it re-prices this card to p1 and puts the remedy on the maintainer floor (安全/权限边界).
⇒ Your first deliverable is that measurement, not a diff. Establish what an unscoped
/packages*actually exposes underprojectResolution: 'required'. If it crosses an environment boundary: ⛔ write no fix.status: needs_decision,pr: null, non-emptyopen_questionsis the correct and complete delivery, and I file it to the decision box as a p1 security item. ⛔ A security remedy is ⛔ never a dev's to choose, and ⛔ never mine.⭐ And if it does NOT leak — read this before you touch the code
I read the site, and the code says the asymmetry is deliberate.
packages/runtime/src/dispatcher-plugin.ts:1756-1768, verbatim:ONE condition rather than the three-way branch its siblings take, and the difference is deliberate:
registerAutomationRoutes/registerActionRoutes/registerAIRoutesDROP their unscoped mounts underprojectResolution: '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.That is #16781's residue under ruling C′ on #14503 step 2 — a recorded decision, ⛔ not an oversight.
⇒ The reflex fix is the one thing explicitly forbidden. Moving
registerPackageRoutes(prefix)(called unconditionally at:1398, several hundred lines before therequiredbranch at:1741) into that branch removes a mounted route surface — precisely the 「silently tidied into a removal」 the comment refuses, with a blast radius the comment names. ⛔ Do not do it.⇒ Default remedy, absent a leak: the DOC is the drift.
content/docs/api/environment-routing.mdx:92over-claims, verbatim: 「required| Registers only environment-scoped routes for data/meta/AI/automation/package handlers.」 The code's shape is ruled; the sentence describing it is not true of package handlers. 「实现未被裁错的说明书脱节 ⇒ 改文档」.⚠️ ⛔content/docs/**isdomain:devx, ⛔ not this lane — but the anchoring rule reads 「docs 随所记录的面走」 and this page documents the dispatcher's own posture, so a one-line correction beside the ruled code is in scope here. ⛔ If your reading says the honest fix is a larger docs restructuring, stop and report rather than widening across a lane boundary.Zone 2 — PM MECHANICAL ASSUMPTIONS (⭐ falsify freely)
Measured by me on
origin/main=d07fc178:# assumption reading 1 registerPackageRoutesis defined and called outside therequiredbranch✅ defined :1342, called unconditionally:1398; therequiredbranch is at:17412 the three siblings DO drop their unscoped mounts under required✅ :1741-1753—registerAutomationRoutes/registerActionRoutes/registerAIRoutesinside the branch3 the scoped package door exists and is the SAME handler ✅ :1769-1771—if (enableProjectScoping) registerPackageRoutes(\${prefix}/environments/:environmentId`)`, 「never re-implemented」4 the doc line still reads 「only」 ✅ environment-routing.mdx:925 ⛔ NOT measured by me whether the unscoped door under requiredcrosses an environment boundary. That is Zone-1's question and the whole card turns on it. ⛔ Do not infer it from the deliberate-asymmetry comment — that comment records that the asymmetry was left standing knowingly; it says nothing about isolationAcceptance — depends on which branch of Zone 1 fires
- Leak found ⇒ the deliverable is the measurement and the report. ⭐ Name the exact call path and what data crossed. ⛔ No fix.
- No leak ⇒ the doc states what the code actually does, and a pin holds the ruled shape:
/packages*stays mounted unscoped underprojectResolution: 'required', and the scoped door is also present. ⭐ That pin is the point — it turns the comment's 「stays that way」 into something a future 「tidy-up」 trips over instead of a paragraph someone has to notice.
⛔ Either way: ⛔ never assert 「no leak」 without a positive control proving the probe could have detected one.
Gates
Derive from
node scripts/pm/dispatch-gates.mjs --commands <your ACTUAL changed paths>on your final diff (⛔ re-derive — a docs-only diff derives a different floor from a code diff). Redirect each, capture$?before any pipe, reconcile with--ran. Read hit lines, ⛔ never counts. This lane always addspnpm lint. A docs-only change may genuinely qualify forskip-changeset— decide explicitly and say which.⚠️ Heavy runs viascripts/pm/os-verify-lock.shwith your ownOS_VERIFY_LOCK_SLOT; an exit 99 queue-timeout is NOT MEASURED, ⛔ not a failure.Report
os-dev-reportas an issue comment first, then the return message; machine-checkable fields only. ⛔ Do not re-narrate the PR body. Draft PR, targetmain, first lineFixes #17432— unless Zone 1 fires, in which case there is no PR and that is correct.⛔ Do not write a second claim comment — this one is your identity.
domain:cli执行席 ·session_01TSf4DV7ziu4V5j73e46b7c(os-sales) · R73 wave 3 · 2026-09-11T08:40Z(date -u,与本次 post 同一调用)
Generated by Claude Code
os-dev-report
{ "issue": 17432, "status": "done", "branch": "claude/issue-17432-package-routes-scoping-asymmetry", "pr": "https://github.com/objectstack-ai/objectstack/pull/17666", "premise_still_valid": true, "zone_1_escalation": "DOES NOT FIRE — no environment-boundary crossing. Measured, not inferred, and never from the deliberate-asymmetry comment. Mechanism: prepareResolverHints gives urlEnvironmentId exactly two sources (an /environments/:id path segment; req.params.environmentId) and the unscoped mount supplies NEITHER, so an unscoped /packages request names no environment 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 one handler behind one dispatch() preamble, so the isolation gates are identical: a non-member is refused 403 PROJECT_MEMBERSHIP_REQUIRED before the domain on BOTH (sys_environment_member queried once per request, 0 package rows served), and a member reaches one byte-identical door through either. With no environment context the request is served by the HOST default (host kernel asked for objectql; both tenant kernels asked 0 times, 0 rows read). What required fails to deliver for package routes is the URL guarantee, not isolation.", "positive_control": "Same door, same assertions, same spies, bound to env_beta by the host resolver (header, order 3): the probe reports env_beta's data plane. The two environments are given data planes that answer differently (registry-bearing vs 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 vs 503 'Package service not available' from the registry-less host default — not only off a spy. So the no-leak legs are readings, not a probe that could not have seen a crossing.", "summary": "Zone 1 measured first and did not fire, so the default remedy applied: the DOC was the drift and no runtime file is edited. registerPackageRoutes(prefix) stays exactly where it is — moving it into the required branch is the removal the mount-site comment refuses. content/docs/api/environment-routing.mdx no longer claims required scopes package handlers: the table row now names the exception and a Callout beside it states both halves (the unscoped /packages surface stays open on a required host, destructive lifecycle verbs included; and that this is not an isolation hole, with the reason). Two new test files in packages/runtime/src: the Zone-1 binding measurement, and the acceptance-named mount pin that holds the ruled shape (unscoped /packages* still served under required, scoped door also present, siblings' unscoped mounts genuinely gone). PM mechanical assumptions 1-4 all confirmed on my base, at the same line numbers (define 1342, unconditional call 1398, required branch 1741, scoped door 1769-1771, doc line 92).", "gates": "Derived on the FINAL diff: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths passed; the tool derives its own change set from the merge base) = 80 families. Reconciled: `--ran` with per-command 'command :: exit N' records = 80 derived, 78 run, 2 NOT MEASURED, 0 UNRUN. Every exit captured BEFORE any pipe into a redirected log. The 2 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 here) — the gates' own text says exit 3 is neither pass nor finding; that prerequisite is CI's Build Core. `pnpm --filter @objectstack/spec check:skill-examples` was in that class at first (exit 1: client-react/dist holds no .d.ts); after building @objectstack/client + @objectstack/client-react it is a real reading, exit 0, '258 prose examples type-check across 3 surface(s)'. Lane addition `pnpm lint` (eslint . --no-inline-config, whole repo, NOT narrowed): exit 0. Closure build `pnpm --filter '@objectstack/runtime^...' build`: exit 0.", "tests": "pnpm --filter @objectstack/runtime typecheck: exit 0 ('check:test-typecheck: OK — @objectstack/runtime's test layer compiles under packages/runtime/tsconfig.test.json'); its FIRST run was red (4 TS18048 in the new test file) and was fixed at the source, never ledgered. pnpm --filter @objectstack/runtime test (whole `local` project): exit 0 — 'Test Files 256 passed (256) / Tests 3588 passed (3588)'. New files alone: 14/14 passed. ABLATION (reverse verification of the new pin): mutated dispatcher-plugin.ts so the unscoped mount is skipped under required — the exact forbidden tidy-up — proved on disk first (anchor occurrences 1 -> 0, injected text 1, blob 3f45087086 -> 54dd097ef6), giving 'Test Files 1 failed | 1 passed (2) / Tests 3 failed | 11 passed (14)': red on exactly the three unscoped package cases (first failure line: 'GET /api/v1/packages -> 404 ENDPOINT_NOT_FOUND'), green on the scoped cases, the dropped-sibling controls and the binding measurement. Restore leg pinned to HEAD (git checkout HEAD -- path, trap-armed, absolute paths) reproduced the HEAD blob byte-for-byte (3f45087086 == 3f45087086, git diff HEAD empty) and re-ran 14/14 green. No build/dist leg applies: both test files import ./dispatcher-plugin.js relatively, so the mutation is read from source, not from dist. Heavy runs via scripts/pm/os-verify-lock.sh slot issue-17432-dev; verdicts read from the wrapper's own 'VERDICT command-exit' line, never a bare $?; no exit 99 occurred (longest wait 24s).", "changeset": "skip-changeset — decided explicitly and measured. @objectstack/runtime publishes [dist, README.md, CHANGELOG.md]; after a real build, grepping that surface for symbols unique to the two new test files returns 0 files each (packages-unscoped-environment-binding, required-scoping-mounts, reachedPackagesDoor, servedByHostDefault), 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 green. Label applied additively (POST .../issues/17666/labels, HTTP 200) and READ BACK after the labeler window: present = [documentation, size/m, tests, skip-changeset] — nothing I set was stripped.", "line_budget": "n/a — no skills/** path in the diff (nothing published under skills/ is touched, so no per-file or per-package line/token readings are owed).", "deviations": [ "Base is d46deba1, not the d07fc178 the claim measured on: origin/main had moved before the worktree was cut. All four PM mechanical assumptions were re-verified on d46deba1 and hold at the SAME line numbers.", "A SECOND test file beyond the one pin acceptance names: packages-unscoped-environment-binding.test.ts. Rationale — the card forbids asserting 'no leak' without a positive control, and a control that lives only in a report comment is not checkable by the reviewer. Both files are inside the declared file surface (tests under packages/runtime/src/).", "PR body attribution uses AGENTS.md's single session-URL footer form (blank line, rule, ONE footer line) rather than the harness's default two-line attribution string, because AGENTS.md governs GitHub artifacts here and forbids a doubled footer. Read back after the write: stored byte-identical, exactly 1 footer occurrence, no angle-bracket fragment mutated.", "Two derived gates are NOT MEASURED rather than green (exit 3 PREREQUISITE NOT MET, whole-repo dist). This is a prerequisite gap owned by CI's Build Core, NOT a declared narrowing of scan surface — pnpm lint ran unnarrowed over the whole repo." ], "files_changed": [ "content/docs/api/environment-routing.mdx (edited: 1 table row + 1 Callout)", "packages/runtime/src/packages-unscoped-environment-binding.test.ts (new: the Zone-1 measurement)", "packages/runtime/src/dispatcher-plugin.required-scoping-mounts.integration.test.ts (new: the mount pin)" ], "files_read_not_edited": [ "packages/runtime/src/dispatcher-plugin.ts (the ruled site — read only; the reflex fix was NOT taken)", "packages/runtime/src/domains/packages.ts (OUT, held by #17416 — read only)", "packages/runtime/src/http-dispatcher.ts, packages/rest/src/rest-api-plugin.ts, packages/rest/src/direct-mount-composition.ts, packages/rest/src/package-routes.ts, packages/rest/src/rest-server.ts (read only)" ], "mcp_calls": "2 — mcp__github__issue_read (get) and mcp__github__issue_read (get_comments) on #17432. Every other GitHub act went through repo-scoped REST after a reachability probe (GET .../issues/17432 -> HTTP 200): PR create (HTTP 201, draft true, base main), body read-back, label add, label read-back, this report comment.", "out_of_scope_findings": [ "noted, not filed: the scoped-URL example block at the top of environment-routing.mdx lists data/meta/automation/ai but not packages, though the scoped package door has existed since #16781. It reads as illustrative rather than exhaustive and the page description does name package calls, so it is not a false claim. Successor: the next domain:devx pass over this page.", "noted, not filed: nothing pins direct-mount-composition.ts's required behaviour (scoped base only for POST /packages/publish); the PR body's claim about it is a direct read of that file. packages/rest is outside this card's file surface, so no test was added there. Successor: whoever next touches the REST direct-mount registrars." ], "open_questions": [] }
Generated by Claude Code
Review verdict: ACCEPT — PR #17666. ⭐ The escalation condition did not fire, and I verified that myself rather than adopting it.
domain:cliexecution seat (#6024), R73, sessionsession_01TSf4DV7ziu4V5j73e46b7c(os-sales), 2026-09-11T09:39Z (date -u, same call as this post). Recorded reviewer; ⛔ every row measured against the tree.The security question — the only one that mattered before any diff
Triage's condition was: if an unscoped
/packages*underprojectResolution: 'required'reaches another environment's package data ⇒ p1, maintainer floor, ⛔ no fix. I re-derived the mechanism independently, because a 「no leak」 adopted from a report is not a reading.packages/runtime/src/http-dispatcher.ts:1271,prepareResolverHints, verbatim:const urlEnvironmentId = this.extractEnvironmentIdFromPath(path) ?? context.request?.params?.environmentId; if (urlEnvironmentId) context.urlEnvironmentId = String(urlEnvironmentId);
⇒ exactly two sources, and the unscoped mount supplies neither — no
/environments/:idsegment, noenvironmentIdparam. So an unscoped/packagesrequest names no environment;urlEnvironmentIdstays unset and the host resolver's later orders decide. The scoped URL is order 1, the stronger addressing primitive.⇒ ⭐ What
requiredfails to deliver for package routes is the URL guarantee, ⛔ not isolation. That distinction is the whole card, and it is the dev's phrasing — accurate.⭐ The positive control is real, named as such, and I checked it
I wrote 「⛔ never assert 'no leak' without a positive control proving the probe could have detected one」.
packages/runtime/src/packages-unscoped-environment-binding.test.ts(309 lines) carries it explicitly:it('POSITIVE CONTROL: the same probe DOES report a tenant environment when the host binds the request to one')And it is readable off the response, not only off a spy — the two environments are given data planes that answer differently, so which one served is visible in the answer itself. Counted in the file:
PERMISSION_DENIED×3 (the ADR-0106 D4 capability gate inside a registry-bearing tenant) vsPackage service not available×2 (the registry-less host default),env_beta×4,403×6,503×3.⇒ the probe demonstrably can see a tenant environment answering when one is bound ⇒ its silence on the unscoped path is a reading, ⛔ not a blind spot. The condition genuinely does not fire.
Isolation parity is pinned on both mounts too: 「a non-member is refused for TENANCY on both mounts, before the domain, with no package row served」 and 「a member reaches the SAME door through either mount」.
⭐ The reflex fix was refused — and the pin now makes refusing it mandatory
dispatcher-plugin.tsis infiles_read_not_edited.registerPackageRoutes(prefix)stays exactly where it is.And the ablation is the part I asked for and did not expect to get so directly: it mutated the dispatcher to skip the unscoped mount under
required— the exact tidy-up the mount-site comment forbids — proved the mutation on disk first (anchor 1→0, blob3f450870→54dd097e), and got 3 failed / 11 passed, red on precisely the three unscoped package cases (first failure:GET /api/v1/packages -> 404 ENDPOINT_NOT_FOUND), green on the scoped cases, the dropped-sibling controls and the binding measurement. Restore reproduced the HEAD blob byte-for-byte with an emptygit diff HEAD.⇒ ⭐ My acceptance ask was 「turn the comment's stays that way into something a future tidy-up trips over rather than a paragraph someone must notice」. That is now literally true.
The doc states BOTH halves, which is better than either
content/docs/api/environment-routing.mdx, +20/−1 — one table row plus oneCallout:- the row now names the exception (and distinguishes the REST
POST /packages/publishroute from the dispatcher's own/packages*bridge — a distinction I did not know about); - the callout says the unscoped surface stays open on a
requiredhost, destructive lifecycle verbs included, and that this is ⛔ not an isolation hole, with the reason, pointing at the recorded deliberate asymmetry at the mount site.
⇒ ⭐ A bare warning would have implied a leak; a bare 「it's fine」 would have hidden an open surface. Stating both is the honest shape. And +20/−1 is a correction, ⛔ not the restructuring I said should stop and report — the scope boundary held.
Checklist
item reading PR form draft, base main, first body lineFixes #17432✅files vs surface 3 — the doc + two pins under packages/runtime/src/. ⛔dispatcher-plugin.tsread-only; ⛔domains/packages.tsuntouched (sibling #17416 holds it); ⛔packages/client/src/index.tsuntouched (fenced) ✅path face 0 governed hits — ⚠️ content/docs/**is explicitly NOT governed ⇒ ordinary ready → queue ✅changeset skip-changeset, measured: after a real build, the published surface (dist, README, CHANGELOG) returns 0 files for each symbol unique to the new tests, with positive controls returning 6 / 2 / 3 (createDispatcherPlugin,registerPackageRoutes,PROJECT_MEMBERSHIP_REQUIRED);apps/docsisprivate: true. Label read back after the labeler window — nothing stripped.Check Changeset→skipped, so ⛔ noClause-②line owed ✅gates 80 derived / 78 run / 2 NOT MEASURED / 0 UNRUN, per-command exits captured before any pipe. The 2 are exit 3 PREREQUISITE NOT MET(whole-repo dist; 37 packages unbuilt) ⇒ CI's Build Core. ⭐ And one gate was promoted out of that class:check:skill-examplesfirst exited 1 on a missingclient-react/dist, so the dev built the dependency and re-ran it to a real reading — exit 0, 「258 prose examples type-check across 3 surface(s)」 ✅tests runtime localproject 256 files / 3588 tests, exit 0; the two new files 14/14 ✅lint eslint . --no-inline-configwhole repo, exit 0, ⛔ unnarrowed ✅CI 17 green / 4 skipped / 10 running / 0 failures ✅ ⭐ One thing handled exactly right and worth naming:
typecheck's first run was red (4 × TS18048 in the new test file) and it was fixed at the source, never ledgered. ⛔ Adding a debt-ledger entry to get green is the move this repo's gates exist to prevent, and it wasn't taken.Deviations — assessed, none rejected
- Base
d46deba1, not thed07fc178my claim measured on. Accepted; all four PM assumptions re-verified on the new base and they hold at the same line numbers (define 1342, unconditional call 1398,requiredbranch 1741, scoped door 1769-1771, doc line 92). - ⭐ A second test file beyond the one acceptance named, with this rationale: 「the card forbids asserting 'no leak' without a positive control, and a control that lives only in a report comment is not checkable by the reviewer」. Accepted, and that instinct is right — it is precisely why I could verify the security claim above from the tree instead of taking the report's word. Both files are inside the declared surface.
- Footer in AGENTS.md's single session-URL form; body read back byte-identical, exactly 1 footer. Accepted.
- 2 gates NOT MEASURED rather than green — declared as a prerequisite gap owned by CI, ⛔ explicitly not a narrowing of scan surface, and
pnpm lintran unnarrowed. Accepted.
out_of_scope_findings— both correctly 「noted, not filed」- The scoped-URL example block at the top of the page lists data/meta/automation/ai but not packages. ⛔ Correctly not filed: it reads as illustrative rather than exhaustive and the page description does name package calls ⇒ ⛔ not a false claim. Successor named: the next
domain:devxpass over the page. ⚠️ Nothing pinsdirect-mount-composition.ts'srequiredbehaviour (scoped base only forPOST /packages/publish) — the PR body's statement about it is a direct read of that file, ⛔ not a pinned fact.packages/restwas outside this card's surface, so no test was added there. ⇒ correctly not filed, successor named. I am carrying this one to the round report as the honest limit of what landed.
What remains
⛔ Not enqueued — 10 checks still running; enqueue resistance is every check green. On green: ready → arm → queue → MERGED on two readings, then close
completedand strippm:dispatchedwith a targeted DELETE. ⛔ Nothing for the maintainer: the posture this documents was already ruled (C′ on #14503 step 2), and the doc now makes it discoverable rather than implicit — which is the improvement this card delivers.domain:cli执行席 ·session_01TSf4DV7ziu4V5j73e46b7c(os-sales) · R73 · 2026-09-11T09:39Z(读表)
Generated by Claude Code
- the row now names the exception (and distinguishes the REST
LANDED — closing
completed.domain:cliexecution seat, 2026-09-11T10:25Z.PR #17666 merged through the merge queue. Both landing readings taken on
origin/mainafter a fresh fetch, ⛔ not on the shared checkout's working tree.Reading 1 — the landing shape
$ git rev-list --parents -n 1 e1fdaf8b039a0aa02fc3b3d8ca6166f74cc19ba3 e1fdaf8b039a0aa02fc3b3d8ca6166f74cc19ba3 4ecfd2b517252ce7fb1c7db7f5c282b715d88bd7 field count: 22 fields ⇒ single-parent squash.
e1fdaf8b, 2026-09-11T10:02:55Z,claude[bot].⚠️ Worth recording as a platform reading, because it contradicted what the API said. This seat requestedmergeMethod: SQUASHand the tool answered "Auto-merge enabled … (method: merge)", and thepull_request.auto_merge_enabledevent also carried"merge_method":"merge". The landing is nevertheless a single parent. ⇒ the reportedmerge_methodon an auto-merge enable is not the shape the queue produces here; onlygit rev-list --parentsanswers that. 判据取命令输出 — ⛔ the API field literal was wrong and the command was right.Reading 2 — controlled content read on
origin/maingit show --stat e1fdaf8b— exactly the declared diff, nothing more:content/docs/api/environment-routing.mdx | 21 +- packages/runtime/src/dispatcher-plugin.required-scoping-mounts.integration.test.ts | 153 ++ packages/runtime/src/packages-unscoped-environment-binding.test.ts | 308 ++ 3 files changed, 481 insertions(+), 1 deletion(-)⭐ Three files, and not one of them is a runtime source file. That is the load-bearing confirmation, not a formality: the option this card listed first was to move
registerPackageRoutesinto therequiredbranch, and the mount site atdispatcher-plugin.ts:1756–:1768refuses exactly that tidy-up in writing. The landed diff proves the refusal was honoured rather than merely promised.The corrected row, read off
origin/mainatcontent/docs/api/environment-routing.mdx:92:|required| Registers only environment-scoped routes for data/meta/AI/automation handlers, and for the RESTPOST /packages/publishroute. The dispatcher's own/packages*bridge is the exception — it stays mounted unscoped too. | Hardened clients that always pass an environment id. |and the warning beside it at
:95: "requireddoes not close the unscoped/packages*surface."Callout type="warn"present.Both pins exist on
origin/main(git ls-tree -r --name-only), and the measurement test carries its control:POSITIVE CONTROL×2 andenv_beta×4. Fabricated negative control in the same run — a symbol that cannot exist (zzz_not_a_real_symbol_17432) → 0 across both paths, so the non-zero counts above are readings and not an always-true grep.What this card settled
What
projectResolution: 'required'does not deliver for package routes is the URL guarantee — "every caller has named an environment in the path" — ⛔ not isolation. The escalation condition did not fire, and the mechanism is now pinned rather than argued:http-dispatcher.ts:1271'sprepareResolverHintsgivesurlEnvironmentIdexactly two sources (an/environments/:idpath segment, andreq.params.environmentId), and an unscoped mount supplies neither. ⇒ a documentation defect, not a tenancy one.⚠️ The honest limit, carried forwardNothing pins
direct-mount-composition.ts'srequiredbehaviour. The landed doc row asserts that@objectstack/rest's own package registrar honoursrequired(scoped base only forPOST {base}/packages/publish), and that assertion rests on a direct read of that file with no test holding it.packages/restwas outside this card's file surface, so the PR correctly added nothing there. ⇒ one-third of the corrected row is unpinned prose. Successor recorded on the PR's acceptance notes; ⛔ this card does not close that gap and does not claim to.Also recorded there and ⛔ not fixed here: the scoped-URL example block at the top of the page lists
data,meta,automationandaibut notpackages, though the scoped package door has existed since #16781. Illustrative rather than false, so a note; successor is the nextdomain:devxpass over that page.skip-changesetwas decided by measurement, not assumed: the published surface grep returns 0 for symbols unique to the two new test files, with positive controls in the same grep returning 6 / 2 / 3.pm:dispatchedstripped by targeted single-label DELETE (HTTP 200), read back:priority:p2, domain:cli. ⛔domain:*and the priority stay — 归属不是状态.domain:cli执行席 ·pm:seat#6024 · sessionsession_01TSf4DV7ziu4V5j73e46b7c· 2026-09-11T10:25Z
Generated by Claude Code
Found while landing #16781. Pre-existing, unchanged by that PR, and filed rather than fixed there: removing a currently-mounted route surface is a different change with a different blast radius than adding a missing door.
The contract, verbatim
content/docs/api/environment-routing.mdx, theprojectResolutiontable:"only environment-scoped" and "package handlers" are both explicit.
What the code does
packages/runtime/src/dispatcher-plugin.tshonours that for three of the four families named —registerAutomationRoutes,registerActionRoutes,registerAIRoutesall sit inside therequiredbranch and drop their unscoped mounts:The package routes are not in that branch at all.
registerPackageRoutes(prefix)is called unconditionally, several hundred lines earlier, soGET/POST/PATCH/DELETE /api/v1/packages*stay mounted on arequiredhost regardless.Why it matters rather than being a doc nit
requiredis the posture a host picks to guarantee every caller names an environment. A host that selected it to close the unscoped surface still serves the whole unscoped/packagesfamily — includingDELETE /api/v1/packages/:idand the destructive lifecycle verbs — with no environment segment in the URL. The operator's mental model and the served surface disagree, and the doc is what set the model.State after #16781
#16781 added the scoped package mount, so on a
requiredhost the scoped routes the doc promises now exist. The unscoped ones the doc says should be absent still do too. The asymmetry is recorded in a comment at the mount site rather than silently tidied into a removal.Two options, both a decision rather than a repair
registerPackageRoutesin the same three-way branch as its siblings. This REMOVES a mounted route surface onrequiredhosts; it needs a ruling on whether any host relies on it, since it can 404 a caller that works today.requirednarrows only data/meta/AI/automation.⛔ Not choosing here; option 1 is a wire removal and belongs to whoever owns that call.
Related: #16781 · #14503 ·
content/docs/api/environment-routing.mdx.