Repository navigation
fix(runtime): PATCH reaches a declared AI route through the /ai/* wildcards, and an undeclared method answers 405 - #21823
Conversation
Both directions at both bases registerAIRoutes serves. Red on the unfixed source: a declared PATCH answers the adapter's 405, and the AI route table answers an undeclared PUT with 404 ROUTE_NOT_FOUND. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…clared path 405 Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…where they are read; changeset Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…which carries Allow Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 26 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 7a12da1370e5326ec42a0e4db6be2efcb0c5767f && git checkout 7a12da1370e5326ec42a0e4db6be2efcb0c5767f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dd58618c2838c01f343a6f859fd4bb28531097d2 f51a2b3944ff12ab92773b85b442ecca3a2c7067 && git checkout -B drift-repro dd58618c2838c01f343a6f859fd4bb28531097d2 && git merge --no-ff f51a2b3944ff12ab92773b85b442ecca3a2c7067
node scripts/docs-audit/affected-docs.mjs --json dd58618c2838c01f343a6f859fd4bb28531097d2
|
…rehydrate refused as not loaded (objectstack-ai#21822) (objectstack-ai#21833) Fixes objectstack-ai#21822 Clause-②: no ## What changed After a restart whose `kernel:ready` rehydrate refused a protocol-incompatible package (ADR-0087 D1), `GET /api/v1/marketplace/install-local` listed it like any loaded package, and each GET logged a `warn` that it could not read the package's seed rows. Now the rehydrate records each entry it refuses, and the listing serves that entry with a `notLoaded` marker carrying the refusal's code and the declared range. The listing reads no seed rows for it, so the per-request warn is gone. This is the direction triage ruled (`5988934231`): **a marker, not omission**. `packages/cloud-connection/src/marketplace-install-local-plugin.ts` only. ⛔ No change to the refusal itself (its line, level and wording), to DELETE, to the install route or to `handleReseed`. No `packages/spec` path. ## The wire shape (for objectstack-ai/objectui#11645 to render) A refused entry, as an operator (`manage_metadata`) reads it: ```json { "packageId": "com.example.crm", "versionId": "…", "manifestId": "com.example.crm", "version": "…", "installedAt": "…", "notLoaded": { "code": "OS_PROTOCOL_INCOMPATIBLE", "requiredRange": "^16" }, "installedBy": "…" } ``` - `notLoaded` is CLOSED: exactly `code` and `requiredRange`, the handshake diagnostic's own member names (the same `requiredRange` the install door's 422 carries in `error.details`). It is not a spread of the diagnostic. - `notLoaded` stands **in place of** `withSampleData`. The listing does not read the package's rows, so it makes no claim about them: the key is omitted, not `false`. This follows the same rule as `installedBy` for a narrowed caller. - A narrowed caller (authenticated, no `manage_metadata`) gets the same item without `installedBy`. The marker is served to every authenticated caller. - A loaded entry is byte-identical to before: `withSampleData`, no `notLoaded` key. `total` counts marked entries. - After `DELETE` the entry is gone from the ledger and the listing. Once a compatible version is installed over it, the entry is listed as loaded, with no marker. ## Where "not loaded" comes from (H3, measured on the code) I compared two sources: - **A**, the rehydrate's own record of what it refused. - **B**, re-running `checkProtocolCompat` on each ledger entry per request. I chose **A**. It records what the rehydrate did, so it agrees with it by construction. B is a second judgement, and it disagrees with what happened in reachable states: - `rehydrate` returns before the handshake loop when there is no `manifest` service ("no `manifest` service — rehydrate skipped"). B would mark entries whose refusal never happened. - The listing re-reads the ledger directory on every request. The rehydrate's own comment names a ledger shared with "a runtime of another protocol", so an entry written there after this boot was never judged here, and B would report a refusal this runtime never made. The record is keyed by manifest id and holds the refused entry's `installedAt`, which says which entry was refused. The install door is the only writer of a new entry, it always stamps a fresh `installedAt`, and it runs the same handshake first, so it can never write an incompatible entry. So a compatible re-install stops the marker without any write to the record from the install door or from DELETE. `rehydrate` clears the record when it starts. ## Pins (the ruling's, verbatim, plus the order's addition) - "after a restart whose rehydrate refused an entry, `GET /install-local` lists it with the marker and the code." - "A loadable entry is unchanged, and DELETE on the marked entry still works." - No seed-row read and no `withSampleData` warn for the marked entry. Unit (`src/marketplace-install-local-listing-not-loaded.test.ts`, 7 cases): a fresh plugin over a ledger with one refused and one loadable entry. The engine double answers only for registered objects, the way the real one answers `Object '…' not found`. The cases cover: - the marker, on the operator's item and the narrowed caller's; - the closed member set; - zero reads of the marked package's object and zero seed-row warnings, with a lit control that the loadable entry's object was read; - the loadable item, byte-equal to the one a ledger without the refused entry serves; - DELETE; - a compatible re-install clears the marker. Door (`packages/qa/dogfood/test/install-local-listing-not-loaded.dogfood.test.ts`, 7 cases): real showcase boots over one `databaseFile` and one ledger. - Boot 1 installs the CRM package (28 seed rows) and a small loadable package. - Between the boots, the CRM ledger entry is rewritten to declare `^(major-1)`, an install made for an older runtime. - Boot 2's rehydrate refuses it; the capture holds the `error` line. - The GET serves the marker. The loadable item is byte-identical to its boot-1 item. DELETE answers 200, and the listing then holds the loadable package alone. - The no-warning assertion has a lit control: an unreadable ledger file planted before boot 2 makes the same GET log its own `warn` through the same logger, in the same capture window. ## Reverse verification (one-off, not kept) The plugin file was restored to the merge base (`c4d57131`, blob `50a71c2f`) with a trap that restores `HEAD`. Each leg was proven on disk: the `refusedAtRehydrate` count went 6 → 0 → 6, the hash equalled the BASE blob, then the `HEAD` blob `411ad7a9`, and `git diff HEAD` came back empty. Both suites import the plugin from `src`: the unit test by a relative path, and the dogfood `isolated` project aliases `@objectstack/cloud-connection` to `../../cloud-connection/src/index.ts`. So no `dist` leg applies. - Unit, pre-fix: **3 failed | 4 passed (7)**. The marker cases fail, and the read/warn case shows both facts: `readsOfMarkedEntry: ["qa_old_account"]` and the warning `com.example.qaold21822: the installed-apps listing could not read this package's seed rows (qa_old_account: Object 'qa_old_account' not found), so it answers withSampleData: false for it`. With the fix: 7 passed. - Door, pre-fix: **2 failed | 5 passed (7)**. `notLoaded` is `undefined`, and the GET logs `WARN [MarketplaceInstallLocal] com.example.crm: the installed-apps listing could not read this package's seed rows (crm_account: Object 'crm_account' not found; crm_contact: …; crm_opportunity: …; crm_lead: …; crm_activity: …)`. This is the card's own reading, reproduced. With the fix: 7 passed. The preservation pins (the loadable entry, DELETE, the compatible re-install, the preconditions and the capture control) pass on both sides, as preservation pins should. ## Tests and gates All readings are from `claude/issue-21822-listing-not-loaded-marker` after one merge of `origin/main` (`07bf21ff`). Final head: `36817931`. - **Build:** the `@objectstack/cloud-connection` and `@objectstack/dogfood` dependency closures, rebuilt after the merge: `turbo run build`, 63/63 tasks. - **`pnpm --filter @objectstack/cloud-connection test`:** 40 files / 492 tests passed (at `6bb9f960`). The one later commit, `36817931`, changes only the new test's engine double, and that file was re-run at `36817931`: 7/7. - **`pnpm --filter @objectstack/cloud-connection typecheck`:** exit 0. `tsc --listFiles` includes the new test file. - **`pnpm --filter @objectstack/dogfood typecheck`:** exit 0. `tsc --listFiles` includes the new dogfood file. - **Dogfood, narrowed:** `vitest run --project isolated` over `install-local-listing-not-loaded`, `install-local-listing-sample-data`, `install-local-purge-sample-data` and `install-local-no-active-organization`: 4 files / 27 tests passed. This narrowing is proven, not assumed: `git grep -l -E 'MarketplaceInstallLocalPlugin|marketplace/install-local' -- packages/qa/dogfood/test` at HEAD names exactly these four files. The rest of the suite belongs to CI's Dogfood Regression Gate. - **Gate battery:** the 67 commands `dispatch-gates --commands` derives for this change set (the dispatch's 49 plus 18 more), all exit 0 at `36817931`. `dispatch-gates --ran`: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN". - `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET`: eight packages outside this closure had no `dist`. They were built and the gate re-ran green: "106 published require entry point(s) across 66 package(s) load". - `check:where-matcher` caught the new engine double reading a combinator as a field name. The double now refuses what it does not implement: 461/461 conforming. - **`pnpm lint`** (`eslint . --no-inline-config`, the whole repository): exit 0 at `36817931`. - **Stale-tree note:** after the one merge, `origin/main` gained `088428fb` (objectstack-ai#21823: the runtime dispatcher and `scripts/check-route-envelope.mjs`). So `check:route-envelope` ran on its pre-objectstack-ai#21823 copy. CI runs on the merge ref. ## Acceptance notes - **Other not-loaded states stay unmarked; they are outside this ruling, which names the protocol refusal.** None was measured through a public door, and none is filed. - A rehydrate whose `register` throws ("rehydrate failed for …", at `error`) is still listed with `withSampleData`, and gets the per-request seed-row warning. - A cloud-snapshot install whose hot-register failed ("will load on next restart") answers 200 and is listed as installed until the restart. - No `manifest` service at `kernel:ready` ("rehydrate skipped") lists every entry as installed. - If a second not-loaded cause is ever marked, `notLoaded.code` is its slot. Today the shape is closed to the one code. - **Not measured here:** reseed and purge on a marked entry. objectstack-ai/objectui#11645 already says Details offers no action that needs a loaded package. - **Release text:** the pending objectstack-ai#21775 changeset's "(for example, a package the runtime did not load)" stays true, for the first case above. - **The refusal line is unchanged:** same level (`error`), same wording. Only its docblock now says the listing marks the entry. - **Concurrency:** objectstack-ai#21776's dev edits `handleReseed` in the same file. This PR touches only the rehydrate's refusal branch, the record field, `handleList` and a helper after it, so there is no overlapping region. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21806
Clause-②: no
A declared
PATCHAI route is reachable over HTTP, and a method the AI route table does not declare for a path still answers405. Both directions are pinned over HTTP at both basesregisterAIRoutesserves. The declaredPATCHroute here isPATCH /api/v1/ai/conversations/:id: the SDK'sai.conversations.updateand the console's conversation rename.What changed
packages/runtime/src/dispatcher-plugin.ts.registerAIRoutesmountspatchon the/ai/*method wildcards besideget,post,deleteandput, at every base. There is no per-route case. The wildcard hands every verb to the AI route table.packages/runtime/src/domains/ai.ts. The AI route table now tells its two misses apart, and the rule is the same for every verb:405 METHOD_NOT_ALLOWED. TheAllowheader names exactly the declared methods, and no handler runs.404 ROUTE_NOT_FOUND.buildApiError, which derives the code from the status (ADR-0112). It is hand-rolled only to add theAllowheader, asdomains/meta.tsanddomains/mcp.tsdo. No new error code.scripts/check-route-envelope.mjs. The hand-built count forai.tsgoes from 1 to 2. The note gives the reason: the 405 has to carryAllow.dispatcher-plugin.route-auth-deny-body.test.tsanddispatcher-plugin.streaming-fallback.test.tssaid that a PATCH route under/ai/*reaches the concrete hook-route mounts unshadowed. This PR makes that false, so the comments now describe the new behaviour. Their drives call the mounted handler directly, so what they pin is unchanged..changeset/21806-runtime-ai-patch-mount.md.@objectstack/runtimepatch.Where the 405 came from (measured)
Each reading below was taken over HTTP with the new pin file:
plugin-hono-serverplus the dispatcher, driven with realfetch, one committed state at a time. Both bases gave the same answers.49c3bfb1(pins only, main's source)Allow: DELETE, GET, HEAD, POST, PUTROUTE_NOT_FOUNDa687964e(adds thepatchmount only)ROUTE_NOT_FOUND41a9c5d7onward (adds the table's 405)METHOD_NOT_ALLOWED,Allow: GETAllow: GET, PATCHROUTE_NOT_FOUNDThe first row reproduces the card's measurement exactly: same status, same code, same
Allowlist.The dispatch order listed five mechanism assumptions. Each was measured:
get,post,deleteandputonly.IHttpServer.patchis a required member. The new mount is really served: a real PATCH reaches the handler at both bases (row 2).registerAIRoutesserves two bases:${prefix}and${prefix}/environments/:environmentId.projectResolution: 'auto'it serves both.requiredit serves only the scoped base.auto, so every case runs at both bases.ai:routesand__aiRoutesare written by cloud'sservice-ai. The pin installs a test table instead:GETandPATCHon/api/v1/ai/conversations/:idandGETon/api/v1/ai/models, allauth: false.Allowlist had noPATCH, so it had no concrete mount either.plugin-hono-server'sunmatchedResponse()), and only because PATCH was not mounted.patchalone therefore turns an undeclared PATCH into a 404 (row 2), which breaks the ruling "An undeclared method still answers 405."What a caller sees change besides the declared PATCH
ROUTE_NOT_FOUND. It now answers 405METHOD_NOT_ALLOWEDwithAllow.ROUTE_NOT_FOUND.Tests
All readings come from commits on this branch.
New
packages/runtime/src/dispatcher-plugin.ai-wildcard-methods.integration.test.ts. At each base it pins four cases:params.idand the JSON body.error.codeisMETHOD_NOT_ALLOWED, the header isAllow: GET, and no handler is called.Allow: GET, PATCH, and no handler is called.ROUTE_NOT_FOUNDwith noAllowheader, and no handler is called.At
f51a2b39with--reporter=verbose, the result isTests 8 passed (8).Red before the fix. Reading the first two rows of the table above as failing pins:
49c3bfb1gave 8 failed of 8, anda687964egave 4 failed and 4 passed. The 4 that failed there were the two 405 cases at each base.Ablating the mount from the finished state (
936c2ca7).scripts/ablation-replace.mjsdeleted thepatchwildcard line. The anchor went from 1 to 0, and the file's blob went from0e4af971f8cato015022c8289e.0e4af971f8ca, andgit diff HEADwas empty afterwards.src, so there is nodisthop in the path being ablated.@objectstack/runtime, run at936c2ca7. The only commit after it,f51a2b39, touches onlyscripts/check-route-envelope.mjs, and no runtime test reads that file.vitest run --project local:Test Files 326 passed (326),Tests 4632 passed | 19 skipped (4651).--project repo:Test Files 3 passed (3),Tests 751 passed (751).typecheck:tsc --noEmitis green.check:test-typecheckreports OK, with the debt ledger unchanged.tsc -p tsconfig.test.json --listFilesincludes the new test file and reports zero errors for it.Gates at
f51a2b39.node scripts/pm/dispatch-gates.mjs --commandsderived 82 families. All 82 were run, all exited 0, and--ranreconciled the list: 82 derived, 82 run, 0 NOT-MEASURED, 0 UNRUN, with every family carrying its exit code.pnpm check:pm-dispatch-gateswas run detached, as its header prescribes:dispatch-gates self-test: 1976 cases pass.pnpm lint(the fulleslint . --no-inline-config) exited 0.Acceptance notes
auth: falseroutes.Allowheader lists the methods the table declares.mountRouteOnServerhas noputarm. It mounts get, post, delete and patch, so a PUT route emitted throughai:routesis never mounted concretely. Under/ai/*the wildcard serves PUT, so this has no measured reach, and no producer of a PUT route outside/ai/*is named. Noted, not filed.RouteDefinition.method's vocabulary, but the wildcards never dispatch either. Hono answers HEAD from the GET wildcard and dispatches it as GET. Dormant: no producer is measured. Noted, not filed.createHonoApp's catch-all dispatches the raw method. On that composition, a HEAD to a GET-only AI path now answers 405 withAllow: GETinstead of 404. This is the same table rule, not a special case.patchwildcard is registered before the concrete hook-route mounts, as the other four already were. A PATCH route thatai:routesalso mounts concretely is now shadowed by the wildcard, so PATCH uses the ExecutionContext-backedreq.userlike the other verbs.Generated by Claude Code