Skip to content

fix(rest): give POST /packages/publish an owner on every boot (#7563) - #7666

Merged
os-help merged 1 commit into
mainfrom
claude/issue-7563-packages-publish-mount-or-404
Aug 11, 2026
Merged

fix(rest): give POST /packages/publish an owner on every boot (#7563)#7666
os-help merged 1 commit into
mainfrom
claude/issue-7563-packages-publish-mount-or-404

Conversation

@os-help

@os-help os-help commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7563

The defect

POST /api/v1/packages/publish answered 405 with Allow: DELETE, GET, HEAD, PATCH. Not one of those verbs belongs to the publish surface — POST is the only verb it has ever had. They are /packages/:id's method set, offered because with the publish route unmounted that pattern was the only registration still matching the path, with id = "publish". The caller was told "this path exists, use another method", and every method on offer would have operated on a package literally named publish.

Which outcome, and why

The real publish handler answers wherever a package service is composed; an honest 404 naming the surface everywhere else. Both, because the root cause turned out to be two separate things:

1. The gate was a boot-instant snapshot, not a composition fact. The registrar was gated on ctx.getService('package') resolving at the one moment RestApiPlugin.start() ran. objectstack serve registers capability providers (requires: ['marketplace']PackageServicePlugin, serve.ts:2414) after createRestApiPlugin (serve.ts:2138), and resolvePluginOrder preserves registration order for plugins with no edge between them. So on showcase — which declares requires: ['marketplace'] — the service exists at request time and was absent at exactly the instant the mount decision was taken. The service is now handed to the registrar as a resolver and read per request.

2. "Not mounted" never meant "404" for this path. POST /packages/publish has no dispatcher twin, so leaving it unowned did not degrade to the 404 direct-mount-composition.ts documented — it degraded to a sibling's 405. It now mounts unconditionally and answers its own 404 naming the surface.

404 rather than 503: a deployment that composed no marketplace capability will not grow one on retry, and 503 invites exactly that retry. It is also the answer the composition module has always documented for a skipped registrar.

The other three package routes deliberately do NOT follow. GET /packages, GET /packages/:id and DELETE /packages/:id each shadow a live dispatcher twin at a byte-identical pattern; mounting them without a service would replace three working routes with a degraded refusal. That asymmetry is pinned so a later "make it consistent" edit goes red.

The parity gate question — did #7584 flag this? No, and the reason generalises

POST /api/v1/packages/publish was pinned in UNEXERCISED_BY_THIS_BOOT, reasoned as "the registrar is service-gated and the showcase composes none". The reason was true and the conclusion was wrong: an unmounted route is not automatically an unanswered one. A conditional mount was excused from the very check that would have caught it — the "LEDGERED BUT NOT MOUNTED, and DISGUISED" failure the gate spells out for every unpinned row.

The pin is deleted (the route is observable for real now) and the pin rule is tightened: a pinned path that any other pattern matches now fails the gate. That probe runs across every verb, which is the subtlety — this class is invisible to a method-scoped check. Nothing registers POST on /packages/:id, so resolveMountedRoute('POST', …) reports a clean absence, while the adapter's allowedMethodsForPath() ignores the request method and builds the 405 from whatever else matches the path. A method-scoped version of this check was written first and failed to fail under mutation; that is what led to the all-verbs form.

A second, unfixed measurement for the PM (not this card)

The same gate cannot see whether the three :id package routes are mounted by REST or by the dispatcher — it compares patterns, and both owners register the same strings. So the REST registrar sitting out on a real objectstack serve boot (cause 1 above) is invisible to it: the ledger says these three "shadow the dispatcher twin (registered first)", and on showcase they currently do not. Behaviour differs (the REST list route merges registry + database packages; the dispatcher's does not). Left alone here — changing which owner serves GET /packages on showcase is beyond this card's scope and is its own risk.

Tests

New packages/rest/src/package-publish-mount.test.ts (10 cases) + two live-router/live-wire pins in the parity gate. Every new test was mutation-proved:

# Mutation Test that went red
M1 publish back to service-gated (: []) 8 cases across both rest files
M2 404 → 503 / SERVICE_UNAVAILABLE 404s with a message about THIS surface…
M3 message names a package id, not the surface 404s with a message about THIS surface…
M4 mount the gated three unconditionally too 5 cases (incl. keeps its hands off the three dispatcher twins)
M5 resolve the service eagerly at mount runs the real handler when the service arrives AFTER the mount decision
M6 404 emitted before the anonymous-deny gate refuses an anonymous caller before disclosing whether the surface exists here
M7 revert the mount and re-add the pin (rebuilt dist) no pinned path is matched by any OTHER pattern, under any verb and POST /packages/publish is owned by the publish route…

Verification

  • pnpm lint ✅ · pnpm typecheck ✅ (126/126)
  • @objectstack/rest full suite ✅ 1415 passed
  • route-ledger-live-mount-parity.dogfood.test.ts (the new parity gate) ✅ 8 passed
  • pnpm check:route-envelope ✅ · client ledger-coverage / url-conformance / response-schema ✅

Changeset: .changeset/packages-publish-mount-or-404.md. No content/docs/releases/** edits. rest-server.ts untouched (#7566 owns /meta/app in this wave).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wpp7qrGdj3e8vdX5LmkmM5


Generated by Claude Code

`POST /api/v1/packages/publish` answered 405 with
`Allow: DELETE, GET, HEAD, PATCH` on a live showcase boot. Not one of those
verbs belongs to the publish surface — POST is the only verb it has ever had.
They are `/packages/:id`'s method set, offered because with the publish route
unmounted that pattern was the only registration still matching the path, with
`id = "publish"`. The caller was told "this path exists, use another method",
and every method on offer would have operated on a package literally named
`publish`.

Two facts produced it, and both are repaired.

The REST package registrar was gated on `ctx.getService('package')` resolving
at the single instant `RestApiPlugin.start()` ran. `objectstack serve`
registers the capability providers (`requires: ['marketplace']` →
PackageServicePlugin) AFTER `createRestApiPlugin`, and start order follows
registration order for plugins with no dependency edge between them — so the
deployments that DO compose a package service are exactly the ones that
answered "no" at mount time. The service is now handed to the registrar as a
resolver and read per request, which makes the answer independent of
composition order instead of silently encoding it.

And `POST /packages/publish` has no dispatcher twin, so "not mounted" never
degraded to the 404 `direct-mount-composition.ts` documented — it degraded to a
sibling's 405. It therefore mounts unconditionally and answers its own honest
404, naming the surface rather than a package id, where no package service is
composed. The other three package routes deliberately do NOT follow: each
shadows a live dispatcher twin at a byte-identical pattern, so mounting them
without a service would replace three working routes with a degraded refusal.

The #7526 route-ledger <-> live-mount parity gate did not flag this: the row
was PINNED in UNEXERCISED_BY_THIS_BOOT, reasoned as "the registrar is
service-gated and this boot composes none". The reason was true and the
conclusion was wrong — an unmounted route is not automatically an unanswered
one. The pin is deleted (the route is observable for real now), and the pin
rule is tightened: a pinned path that any OTHER pattern matches now fails the
gate. That probe runs across every verb on purpose, because this class is
invisible to a method-scoped one — nothing registers POST on `/packages/:id`,
so `resolveMountedRoute('POST', ...)` reports a clean absence while the
adapter's `allowedMethodsForPath()` ignores the request method and builds the
405 from whatever else matches the path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wpp7qrGdj3e8vdX5LmkmM5
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 10:09am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest)
  • content/docs/permissions/authentication.mdx (via @objectstack/rest)
  • content/docs/permissions/system-context.mdx (via packages/rest)
  • content/docs/plugins/index.mdx (via @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest)

3 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/rest)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v17.mdx (via @objectstack/rest)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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/l tests tooling

Projects

None yet

2 participants