fix(runtime): mount the scoped /packages dispatcher door and reconcile its read/delete responses to the spec - #17433
Conversation
…e its read/delete responses `mountPackagesRoute` mounted `/packages*` at the unscoped prefix only, while automation / actions / ai each registered a scoped variant twenty lines away. On a host composed as `plugin-hono-server` + the dispatcher with `enableProjectScoping: true` and WITHOUT `@objectstack/hono`'s `createHonoApp`, that left `GET /api/v1/environments/:id/packages`, `GET .../packages/:id` and `DELETE .../packages/:id` answered by the transport's own `notFound`. The domain has resolved scoped package paths since #15859; nothing mounted one. Wrap `mountPackagesRoute` in a `base`-taking `registerPackageRoutes(base)`, the same shape its three siblings use, and call it a second time with the scoped base. Same handler, no second implementation. The unscoped mounts keep their registration position and stay unconditional, so the change is additive. Reconcile both doors to their declared schemas (contract review of #16628, F2), additively — no key leaves either payload: - `GET /packages` sends `hasMore` (false: this door reads no limit/cursor and returns every row that survives its filters). - `DELETE /packages/:id` sends `packageId`. Fill `responseSchema` on the `DELETE /packages/:id` ledger row, backed by new conformance coverage driving the real handler. `GET /packages` is left blank on purpose: its rows carry the ASSEMBLED package body while `InstalledPackageSchema` wraps the AUTHORING-stage `ManifestSchema` (the #14242 stage mismatch), so a name there would be false on every `defineStack()` host. Both directions of that boundary are pinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
📓 Docs Drift CheckThis PR changes 1 package(s): 27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 9a2bec21c47faae095f4c7a7994a340d2d0cd590 && git checkout 9a2bec21c47faae095f4c7a7994a340d2d0cd590
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5d527f7e177473ee7dafdf00e277b1cded3daf42 54ace6a4aae7e76b20d72aa27df14e33a845ee3f && git checkout -B drift-repro 5d527f7e177473ee7dafdf00e277b1cded3daf42 && git merge --no-ff 54ace6a4aae7e76b20d72aa27df14e33a845ee3f
node scripts/docs-audit/affected-docs.mjs --json 5d527f7e177473ee7dafdf00e277b1cded3daf42
|
`check:doc-authoring` refuses new internal issue-id references in sibling-package string prose: a runtime string reaches authors, operators and generated surfaces, none of whom can resolve `#NNNN`. The ids move to the adjacent `//` comment, where the reader who CAN resolve them reads the source. Maintainer ruling 2026-08-12, verbatim: 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
…oped-packages-dispatcher-door
Fixes #16781
The residue PR #16628 was authorised to leave behind (director ruling C′ on #14503 step 2, decision batch #84), repaired. ⛔ Not a defect in #16628 — the bill it was authorised to leave.
Clause-②: yesRe-declared from the DELIVERED diff:
GET /packagesgainshasMoreandDELETE /packages/:idgainspackageId— new keys on published payloads, which is the mechanical floor verbatim. Review of record is the lane seat's default-tier review plus the gates (tier notice on the card, 2026-09-10T03:13Z; rule text merged as PR #17294).Deliverable 1 — the scoped door
mountPackagesRouteclosed overprefixdirectly and mounted/packages*at the unscoped prefix only, whileregisterAutomationRoutes/registerActionRoutes/registerAIRouteseach took abaseand registered a scoped variant twenty lines away.It is now wrapped in a
base-takingregisterPackageRoutes(base)— the same shape as its three siblings — and called a second time with${prefix}/environments/:environmentId. The same handler, no second implementation.dispatch()is still handed the unscoped subpath; the:environmentIdrides onreq.paramsforprepareResolverHints, exactly the convention the action routes document.Two deliberate choices, both recorded at the call sites:
registerPackageRoutes(prefix)stays where the unscoped mounts have always been registered, not moved down beside the scoped call. Hono resolves competing patterns first-registration-wins (the ADR-0076 D11 hazard this file's own fallback note explains), so moving it would be a behaviour change wearing a refactor's clothes.if (enableProjectScoping)rather than the three-way branch its siblings take. The siblings DROP their unscoped mounts underprojectResolution: 'required'; the package routes are mounted unconditionally and stay that way. Adding a missing door and taking one away are different changes with different blast radii. That asymmetry is pre-existing, documented in a comment, and filed separately (below).Deliverable 2 — the response-shape drift
Both reconciliations are toward the spec (protocol is the baseline) and both are ADDITIVE. ⛔ No key left either payload, so triage's fence — 「若需要删字段,停下回报」 — was never reached.
GET /packageshasMoreListInstalledPackagesResponseSchemafalseDELETE /packages/:idpackageIdUninstallPackageApiResponseSchemahasMore: falseis a constant because it is true, not because it is convenient: this door applies itsstatus/typefilters and returns every remaining row, reading nolimitand nocursor, so there is no next page to announce and the optionalnextCursorstays absent.The ledger rows: one filled, one deliberately blank
DELETE /packages/:idnow carriesresponseSchema: 'UninstallPackageApiResponseSchema', backed by new conformance coverage that drives the real handler on both authoring paths.GET /packagesis left without aresponseSchema, andListInstalledPackagesResponseSchematypes each row asInstalledPackageSchema, whosemanifestis the AUTHORING-stageManifestSchema(objects= glob patterns). The registry records the ASSEMBLED body —ObjectQL.registerAppis handedmanifest.objectsas object DEFINITIONS — which is what everydefineStack()host produces and what this door serves.Measured, after
hasMorewas added:ListInstalledPackagesResponseSchema.safeParsedefineStack()shapedata.packages.0.manifest.objects.0That is the #14242 authoring-vs-assembled stage mismatch, one layer up. Its maintainer ruling (2026-09-02, quoted in spec
stack.zod.tsatArtifactPackageSchema) was to «declare the assembled stage rather than widen the authoring one» — apackages/specchange this card is explicitly routed away from. Writing the name anyway would be exactly the "declared but unverified" surface the ledger header forbids: a promise the door keeps only for glob-authored packages. ⇒ Blank, with the verdict written down, and both directions of the boundary pinned so the row becomes fillable against a red test rather than a guess. Filed as #17431.Deliverable 3 — the regression test
packages/runtime/src/dispatcher-plugin.scoped-packages-door.integration.test.tsboots the exact composition over a real socket:HonoServerPlugin+createDispatcherPlugin({ scoping: { enableProjectScoping: true } }), nocreateHonoApp, no@objectstack/rest, no service plugins — nothing that could supply a second door. Kept inpackages/runtime, so it does not touchplugin-hono-server/src/adapter.tswhile PR #17412 is in the merge queue.Acceptance notes
The control, verbatim from the card: "the same request on the same composition answers
ROUTE_NOT_FOUND/bare 404 before and the dispatcher's row after."The discriminator is a positive test, not "not a 404": the anonymous-deny floor is the first statement inside the dispatcher's packages path, so a credential-less request that CROSSES the mount is answered
ANONYMOUS_DENY_STATUS/ANONYMOUS_DENY_CODE— a verdict no transport-level sink emits. Both constants are imported, never spelled, so a rename moves the test with them.BEFORE (same file, run on the unmodified tree — 3 failed, 2 passed):
The two controls passed in that same before-run, which is what makes the three failures a reading rather than a mis-aimed probe:
GET /api/v1/environments/env_alpha/no-such-domain, a scoped path no mount claims, answered the transport's own 404. So the discriminator distinguishes; it does not simply say "something answered".AFTER — the same three requests answer through the dispatcher, both controls still hold, and the whole affected set is green:
(
packages-read-delete-response-conformance·dispatcher-plugin.scoped-packages-door.integration·packages-single-door·packages-uninstall-envelope·packages-readonly-gate·packages-serializable-response)The new pins go red without the change, each by construction rather than by assertion:
hasMore/packageId: each conformance file builds the pre-change body by DELETING the one key that was added, then assertssafeParsefails at exactlydata.hasMore/data.packageId. A statement about the fix, not about a hand-written literal.The residue on the delete row is pinned, not hidden.
UninstallPackageApiResponseSchemadoes not carryregistryRemovedorpersisted, which the door really serves, so a declared parse strips them. The test asserts that stripped set by name, so either side moving turns it red. ⛔ Not fixed here: removing them is a payload deletion (fenced), widening the schema ispackages/spec.Out-of-scope findings
GET /packagesrows are the ASSEMBLED package body, butInstalledPackageSchematypes them as the AUTHORING manifest — the #14242 stage mismatch, one layer up on the read API #17431 — theGET /packagesstage mismatch above. Blocks filling that ledger row; the pinned boundary is its pickup path.projectResolution: 'required'is documented as registering ONLY scoped routes for package handlers, but the dispatcher mounts/packages*unscoped unconditionally #17432 —projectResolution: 'required'is documented as registering only scoped routes for package handlers, but/packages*is mounted unscoped unconditionally. Pre-existing, unchanged by this PR.Noted, not filed:
ListInstalledPackagesRequestSchemadeclareslimit(default 50) andcursor, which this door reads neither of. It rides with #17431 — both are answered by the same look at what this route promises — and it is the reasonhasMoreis honestly constant rather than computed.Line numbers on the card, re-derived
Every coordinate had moved; located by symbol, as instructed.
origin/main93447bbdispatcher-plugin.ts:1306-1330:1301-1320, 17 calls:1322-1346:1653-1667(scoped variants)enableProjectScopingblock at:1686-1703:1711(setFallbackHandler):1729/:1744,isAppEndpointPathat:1753route-ledger.ts:336,339GET /packages:377,DELETE /packages/:id:380package-api.zod.ts:71-77:73-79:358-364:360-366Triage's pairing reproduced on that tree before anything was written:
environments/:id/scopedPrefix/enableProjectScopingat 11 hits in the file, scoped/packagesmounts at 0.Triage's hard first step — read the
cloudrepo's host composition — remains UNFIRED-BECAUSE-UNMEASURED, exactly as the claim comment recorded it.objectstack-ai/cloudis outside this session's GitHub credential. ⛔ It is not a zero. It does not change any of the three deliverables — they are identical under either answer — only the card's urgency.🤖 Generated with Claude Code
https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
Generated by Claude Code