Skip to content

fix(runtime): mount the scoped /packages dispatcher door and reconcile its read/delete responses to the spec - #17433

Merged
os-justin merged 3 commits into
mainfrom
claude/issue-16781-scoped-packages-dispatcher-door
Sep 10, 2026
Merged

fix(runtime): mount the scoped /packages dispatcher door and reconcile its read/delete responses to the spec#17433
os-justin merged 3 commits into
mainfrom
claude/issue-16781-scoped-packages-dispatcher-door

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

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-②: yes

Re-declared from the DELIVERED diff: GET /packages gains hasMore and DELETE /packages/:id gains packageId — 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

mountPackagesRoute closed over prefix directly and mounted /packages* at the unscoped prefix only, while registerAutomationRoutes / registerActionRoutes / registerAIRoutes each took a base and registered a scoped variant twenty lines away.

It is now wrapped in a base-taking registerPackageRoutes(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 :environmentId rides on req.params for prepareResolverHints, 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.
  • The scoped mount is one if (enableProjectScoping) rather than the three-way branch its siblings take. The siblings DROP their unscoped mounts under projectResolution: '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.

route key added required by value
GET /packages hasMore ListInstalledPackagesResponseSchema false
DELETE /packages/:id packageId UninstallPackageApiResponseSchema the package id

hasMore: false is a constant because it is true, not because it is convenient: this door applies its status / type filters and returns every remaining row, reading no limit and no cursor, so there is no next page to announce and the optional nextCursor stays absent.

The ledger rows: one filled, one deliberately blank

DELETE /packages/:id now carries responseSchema: 'UninstallPackageApiResponseSchema', backed by new conformance coverage that drives the real handler on both authoring paths.

GET /packages is left without a responseSchema, and ⚠️ this is where the card's premise did not survive measurement. F2 was not the only thing between that door and its declared schema. ListInstalledPackagesResponseSchema types each row as InstalledPackageSchema, whose manifest is the AUTHORING-stage ManifestSchema (objects = glob patterns). The registry records the ASSEMBLED body — ObjectQL.registerApp is handed manifest.objects as object DEFINITIONS — which is what every defineStack() host produces and what this door serves.

Measured, after hasMore was added:

package authored as ListInstalledPackagesResponseSchema.safeParse
glob patterns success
defineStack() shape failure, one issue: data.packages.0.manifest.objects.0

That is the #14242 authoring-vs-assembled stage mismatch, one layer up. Its maintainer ruling (2026-09-02, quoted in spec stack.zod.ts at ArtifactPackageSchema) was to «declare the assembled stage rather than widen the authoring one» — a packages/spec change 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.ts boots the exact composition over a real socket: HonoServerPlugin + createDispatcherPlugin({ scoping: { enableProjectScoping: true } }), no createHonoApp, no @objectstack/rest, no service plugins — nothing that could supply a second door. Kept in packages/runtime, so it does not touch plugin-hono-server/src/adapter.ts while 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):

GET    /api/v1/environments/env_alpha/packages                 -> 404 {"success":false,"error":{"code":"ENDPOINT_NOT_FOUND","message":"Not found"}}
GET    /api/v1/environments/env_alpha/packages/com.acme.crm    -> 404 {"success":false,"error":{"code":"ENDPOINT_NOT_FOUND","message":"Not found"}}
DELETE /api/v1/environments/env_alpha/packages/com.acme.crm    -> 404 {"success":false,"error":{"code":"ENDPOINT_NOT_FOUND","message":"Not found"}}

The two controls passed in that same before-run, which is what makes the three failures a reading rather than a mis-aimed probe:

  • POSITIVE CONTROL — the UNSCOPED door has always existed, and all three verbs answered from the dispatcher.
  • NEGATIVE CONTROL — 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:

Test Files  6 passed (6)
     Tests  63 passed (63)

(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:

  • the door test's three cases are the before-run's three failures;
  • hasMore / packageId: each conformance file builds the pre-change body by DELETING the one key that was added, then asserts safeParse fails at exactly data.hasMore / data.packageId. A statement about the fix, not about a hand-written literal.

The residue on the delete row is pinned, not hidden. UninstallPackageApiResponseSchema does not carry registryRemoved or persisted, 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 is packages/spec.

Out-of-scope findings

Noted, not filed: ListInstalledPackagesRequestSchema declares limit (default 50) and cursor, 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 reason hasMore is honestly constant rather than computed.

Line numbers on the card, re-derived

Every coordinate had moved; located by symbol, as instructed.

card actual on origin/main 93447bb
dispatcher-plugin.ts:1306-1330 def :1301-1320, 17 calls :1322-1346
:1653-1667 (scoped variants) the enableProjectScoping block at :1686-1703
:1711 (setFallbackHandler) :1729 / :1744, isAppEndpointPath at :1753
route-ledger.ts:336,339 GET /packages :377, DELETE /packages/:id :380
package-api.zod.ts:71-77 :73-79
:358-364 :360-366

Triage's pairing reproduced on that tree before anything was written: environments/:id / scopedPrefix / enableProjectScoping at 11 hits in the file, scoped /packages mounts at 0.

⚠️ The re-grade trigger is still UNMEASURED

Triage's hard first step — read the cloud repo's host composition — remains UNFIRED-BECAUSE-UNMEASURED, exactly as the claim comment recorded it. objectstack-ai/cloud is 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

…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
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 40 documentable anchor(s).

27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5d527f7e177473ee7dafdf00e277b1cded3daf42.

6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: responseSchema (6 routes)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 24 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 5d527f7e177473ee7dafdf00e277b1cded3daf42packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9a2bec21c47faae095f4c7a7994a340d2d0cd590 — the merge of head 54ace6a4aae7e76b20d72aa27df14e33a845ee3f into base 5d527f7e177473ee7dafdf00e277b1cded3daf42, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5d527f7e177473ee7dafdf00e277b1cded3daf42 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
`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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants