Skip to content

Commit 645ed3d

Browse files
fix(cloud-connection)!: install-local cloud branch refuses every register the engine refuses; only a fault stays lenient (#31478) (#31563)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XTVkGrf2CjQTF79hBERgqiJ)_ domain:server#5 · os-project-manager · session_01FEkQ5ktJb5rQ5n2GUXSQMR Fixes #31478 Clause-②: no (narrowing) — a cloud-branch install that answered success (`hotLoaded: false`) for a register the engine refused is now refused 422; one contract-review-tier review owed before the queue Metadata-protocol: none ## 维护者速读 - 改了什么:`POST /api/v1/marketplace/install-local` 的云端分支,凡是注册时引擎抛出自带 4xx 状态的拒绝,一律回 `422 PLUGIN_REGISTER_FAILED`,不写安装记录,和内联分支一致。卡片列的几类全部覆盖:默认记录页冲突、默认页放错位置、对象归属冲突、产物内对象重名、依赖共享命名空间下的对象重名、未声明的选项列表、绑定动作的目标对象不存在。#31392 的三类同名冲突也由同一条规则接住。 - 为什么改:以前这些都走宽容路径,写安装记录,回 200 和 `hotLoaded: false`,声称“下次重启加载”。在 main 上实测,重启时同一条拒绝再次触发,包根本加载不了。 - 宽容路径的去留(卡片要求一次性决定):只留给“没有声明 4xx 的注册失败”,也就是运行时自己的故障(未声明状态的抛错或声明 5xx)。这一类实测重启能治好,作为 CONTROL 钉住。 - 风险与回滚:`refusesNameHeldElsewhere` 和它的类名表被一个判定 `isDeclaredRegisterRefusal` 取代,按 `resolveThrownHttpError(err).declaredStatus` 读,不列任何类名或错误码。回滚即还原本 PR。 - 两分钟验证:`pnpm --filter @objectstack/cloud-connection exec vitest run src/marketplace-install-local-register-refusal.test.ts src/marketplace-install-local-hot-loaded.test.ts`,预期全绿。 - 需要你做的:无,席位落地。 ## What changes `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, the cloud-fetched branch of `POST /api/v1/marketplace/install-local` (a `packageId` + `versionId` body): 1. **One predicate decides the lenient path's fate.** `isDeclaredRegisterRefusal(err)` asks whether the register failure declares a 4xx status of its own (`resolveThrownHttpError(err).declaredStatus`, never a fallback this door supplies). Every refusal the registry's package door and the manifest service's seals raise declares `422` (ADR-0112). It replaces `refusesNameHeldElsewhere` and `NAME_HELD_ELSEWHERE_REFUSALS` (#31392), and the `NAMESPACE_CONFLICT_CODE` import goes with them. 2. **The catch asks it.** When it answers yes, the cloud branch answers `422 PLUGIN_REGISTER_FAILED` with `Package '<id>' cannot be installed: <the engine's sentence>`, before the ledger write and anything else this door writes. 3. **What stays lenient** is a register failure that declares no 4xx: an undeclared throw or a declared 5xx, which is the runtime's own fault and not a verdict on the package. It keeps #22695's answer (`200`, `hotLoaded: false`, `hotLoadError`). 4. **Doc comments.** The module header's install paragraph, the predicate's docblock (what was measured, which refusal a restart can heal and why that is not a reason to ledger it) and the catch comments. What does not change: - The inline branch. - Step 1f's `409 RESOURCE_CONFLICT` for a namespace a writable base holds still answers first. - Under `OS_METADATA_COLLISION=warn`, the namespace gate warns and throws nothing. - No handbook page teaches the lenient path. `content/docs/deployment/cli.mdx` says a package the runtime recorded but could not load "loads when the runtime restarts", which is still true of the fault case. ## Which refusals a restart can heal (measured first, on main 7d7af56) A throwaway probe, not committed, used the same real `ObjectKernel` + `ObjectQLPlugin` harness. A second boot over the same ledger directory is the restart: the `kernel:ready` seals run before the plugin's rehydrate, as on a real boot. - **Each listed class, installed fresh on the cloud branch:** `200`, `hotLoaded: false`, ledgered. At the restart the rehydrate logged the same refusal and loaded nothing (default record page, misplaced default page, unresolved picklist, unbound action target measured through a restart; the object-name classes meet the same owner at every boot). - **A sealed-vocabulary refusal healed by another install.** A package binding an action to `dispatch_job` was installed before the package that owns `dispatch_job` (no declared dependency), and then the owner was installed. At the restart the outcome depended on the ledger's listing order: - binder `com.acme.routing` (listed after the owner): loaded; - binder `com.acme.adispatch` (listed before the owner): refused again, `rehydrate failed for com.acme.adispatch A top-level action is bound by objectName to an object that no loaded package registers`. The rehydrate sorts by declared `dependencies` only, and the reference this refusal names is one the package did not declare. Once declared, the pre-install dependency check refuses the install `422` up front instead. And once the owner is installed, installing the binder again succeeds at once with no restart (pinned below). So this is no reason to ledger it. - **A register fault declaring no status:** ledgered, and the restart loads it (the CONTROL below). ## Design call (the four axes) Two options were on the card: (A) extend #31392's code + name set with the listed classes, or (B) decide the lenient path once for every pre-write refusal. **Chosen: B**, by the status the throw declares. - **Real need.** Every refusal measured above fails again at every boot, so `hotLoaded: false` / "loads at the next restart" was a false success for each. The only case a restart heals is a fault, and B keeps exactly that one lenient. - **Long-term soundness.** A cannot cover the sealed-vocabulary refusals: they are plain `Error`s with `code: 'INVALID_METADATA'`, a code other refusals share (an action that fails `ActionSchema`, a field with no `type`). A would need either message parsing or a new brand in the engine, plus a list to keep in step with every refusal the engine gains. B reads the declaration ADR-0112 already requires of every refusal, so a refusal added later is refused here with no change. - **Making AI mistakes harder.** A lenient `200` for a body the engine refused is the tolerance the axis warns against: an AI-written package with a typo'd picklist or `objectName` was reported installed. Under B it is refused loudly at install, with the engine's sentence naming the fix. - **Startup-stage focus.** One predicate replaces one, with no new surface, gate or field. Abolishing the lenient path outright was weighed and not chosen: it would also refuse the one case a restart does heal, and the card scoped the change to refusals. ## Tests `marketplace-install-local-register-refusal.test.ts` (new) uses the #31296 harness: a real `ObjectKernel` + `ObjectQLPlugin`, booted. The holder is installed through the same door, and the cloud leg is a stubbed `fetch`. - **Seven fresh installs, one per refusal:** - a default record page another package holds; - `isDefault: true` on a `home` page; - an object another package owns; - an object name two packages of one release artifact define; - an object the package it depends on owns; - a picklist no loaded package declares; - a bound action whose object no loaded package registers. Each answers 422 in the declared envelope with the engine's sentence. Nothing is registered, the ledger is exactly as before, and the holder still owns its object. - **Two upgrades** (a registry refusal and a seal refusal): 422, and the installed version 1 is still registered and ledgered. - **The remedy:** the bound-action install is refused, the owner is installed, and the same install then answers `200` with `hotLoaded: true`. - **CONTROL:** a register throwing a plain `Error` keeps `200` with `hotLoaded: false`, `hotLoadError` and a ledger entry, and the restart loads the package. The #31296 / #31392 / #25366 suites and `marketplace-install-local-hot-loaded.test.ts` are unchanged and green. <details><summary>Red on main (the new file against the unfixed plugin, fix reverted in the tree)</summary> ``` × a fresh install of a default record page another package holds is refused: 422 with the engine's sentence, nothing registered or ledgered × a fresh install of a default page that is not a record page is refused: … × a fresh install of an object another package owns is refused: … × a fresh install of an object name two packages of one artifact define is refused: … × a fresh install of an object the package it depends on owns is refused: … × a fresh install of a picklist no loaded package declares is refused: … × a fresh install of a bound action whose object no loaded package registers is refused: … × an upgrade to a default record page another package holds is refused: 422, the installed version still registered and ledgered × an upgrade to a bound action whose object no loaded package registers is refused: … × the remedy a bound action's refusal names: once the object's package is installed, the same install succeeds at once AssertionError: {"success":true,"data":{"manifestId":"com.acme.routing",…,"hotLoaded":false,"hotLoadError":"Default record page conflict: page \"routing_job_detail\" of package \"com.acme.routing\" cannot be the default record page of object \"dispatch_job\": …"}}: expected 200 to be 422 AssertionError: {…"hotLoadError":"A picklist is named that no loaded package declares: field 'routing_visit.status' (package 'com.acme.routing') references picklist 'routing_visit_status'. …"}}: expected 200 to be 422 Tests 10 failed | 1 passed (11) ``` The one pass is the CONTROL. </details> <details><summary>Ablations (fix committed at c5e8a85, each restored)</summary> Both were run through `node scripts/ablation-replace.mjs` on `marketplace-install-local-plugin.ts`. The suites import the plugin from `src/` relatively, so no build applies. 1. The catch's `if (isDeclaredRegisterRefusal(err))` was replaced by `if (false as boolean)`, so no refusal is recognised. The ten new refusal cases go red, and so do the #31296, #31392 and #25366 pins, because the held names are now recognised by the same rule: ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob 20affa1 -> 42a56d5cb8a1 Tests 24 failed | 19 passed (43) ablation-replace: ok restored: blob == HEAD (20affa1) and `git diff HEAD` is empty ``` 2. The predicate's body was replaced by `return true`, which abolishes the lenient path. The CONTROL and the #22695 lenient pin go red: ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob 20affa1 -> 577f2d41d3a1 × a cloud-fetched manifest whose register throws answers 200 with hotLoaded: false and the register error as hotLoadError × CONTROL — a register fault declaring no status keeps the lenient answer, and the restart heals it Tests 2 failed | 13 passed (15) ablation-replace: ok restored: blob == HEAD (20affa1) and `git diff HEAD` is empty ``` </details> ## Verification All checks ran on head `538d1389`: the fix, the tests and the changeset, merged once with `origin/main` at `dabef0f6`. Each exit code was captured before any pipe. - **Full build**, `pnpm turbo run build --concurrency=2`, under the verify lock: VERDICT command-exit 0 (120 of 120 tasks). - **Derived gate union**, `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 69 families, all run after the full build. 68 exited 0 on the first run. `check:type-check-debt` exited 3 (its prerequisite: the lock was held by the dogfood run) and exited 0 when re-run after the lock was released. `--ran` reports "69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED (a DERIVED zero — all 69 recorded an exit code and none of them is 3)". - **Facade gate**, `pnpm check:facade-delegation-only`: exit 0. This diff touches no facade file. - **Package**: `pnpm --filter @objectstack/cloud-connection test`: Test Files 77 passed, Tests 1048 passed (exit 0). `pnpm --filter @objectstack/cloud-connection typecheck` and `tsc -p tsconfig.test.json --noEmit`: exit 0. - **Lint**: `eslint --no-inline-config` on the two touched `.ts` files: exit 0. - **Full dogfood battery**, the `(narrowing)` arm, `pnpm --filter @objectstack/dogfood test` after the full build, under the verify lock: VERDICT command-exit 0. Test Files 440 passed / 3 skipped (443), Tests 3633 passed / 41 skipped (3674). - **Main after the merge.** `origin/main` moved to `6ed533f6` while the checks ran. #31537 and #31529 touch `marketplace-install-local-plugin.ts`, away from the register catch. `git merge-tree --write-tree HEAD origin/main` is clean, and this branch was not merged a second time. - CI-only families (the Test Core shards, the type-check lanes, Build Docs) are read from this PR's check-runs, not re-run here. ## Acceptance notes - **The remaining lenient case,** with the restart that heals it: a register failure that declares no 4xx (an undeclared throw or a declared 5xx). The next restart of the runtime, or of a build that fixes the fault, registers the ledgered entry. That is pinned by the CONTROL. - **Measured beside the card, not changed here.** A multi-package artifact whose second member is refused leaves the first member registered in the running kernel, unledgered, so it is gone at the restart. The manifest service registers an artifact's packages one by one. The inline branch measured the same (`422`, `com.acme.crm` registered, ledger empty). Withdrawing the earlier members is the manifest service's (`@objectstack/objectql`), outside this door, and is named in the report as a follow-up. - **Old ledger entries.** An entry an earlier build ledgered this way is read at boot as before. This PR refuses new ones only. - **Governed surface.** None. The diff is one source file, one new test file and one changeset. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5ktJb5rQ5n2GUXSQMR --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5ktJb5rQ5n2GUXSQMR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dda74d3 commit 645ed3d

3 files changed

Lines changed: 469 additions & 87 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/cloud-connection': patch
3+
---
4+
5+
fix(cloud-connection)!: install-local's cloud branch refuses every register the engine refuses, and keeps the lenient answer only for a fault (#31478)
6+
7+
Clause-②: no (narrowing) — `POST /api/v1/marketplace/install-local` with a cloud-fetched package (`packageId` + `versionId`) whose `manifest.register` throws a refusal declaring a 4xx status answers `422 PLUGIN_REGISTER_FAILED` where it answered `200` with `hotLoaded: false` and wrote the ledger entry. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) no key of any schema is added, removed or re-shaped and no stored row is read differently: the narrowing is one runtime refusal at one door, for register refusals the inline branch of the same door already answers 422, and its remedy is the correction the engine's sentence names, so there is nothing for `objectstack migrate meta` to rewrite. A ledger entry an earlier build wrote this way is read as before at boot. -->
11+
12+
**BREAKING** for a cloud-branch install that relied on the lenient answer for a register the engine refuses.
13+
14+
- **Before.** The cloud branch refused only the registry's three held-name refusals (#25366, #31296, #31392). Every other register refusal was ledgered and answered `200` with `hotLoaded: false`, "loads at the next restart": a default record page another package holds, `isDefault: true` on a page that is not a record page, an object another package owns, an object name two packages of one artifact (or of one namespace shared through a declared dependency) define, a picklist no loaded package declares, a bound action whose object no loaded package registers. The next boot refused each again and loaded nothing.
15+
- **Now.** Each is answered `422 PLUGIN_REGISTER_FAILED` with the engine's sentence, as the inline branch answers it. Nothing is ledgered, and an installed earlier version stays registered and ledgered. The door recognises a refusal by the status the throw declares (any 4xx), never by a list of classes or codes, so the held-name refusals are answered by the same rule.
16+
- **Unchanged.** A register failure that declares no 4xx — an undeclared throw or a declared 5xx, the runtime's own fault — keeps the lenient answer (`200`, `hotLoaded: false`, `hotLoadError`) and loads at the next restart. A namespace held by a writable base is still refused `409 RESOURCE_CONFLICT` before the register.
17+
18+
**The remedy.** Make the correction the refusal names, then install again: rename or un-default the page, rename the object or `extend` the owner's, declare the picklist or the bound object (or install the package that brings it, and declare it in `manifest.dependencies`), or uninstall the holding package.

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 71 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,19 @@
4040
* gives (ADR-0087 D1, #21762), and nothing is registered or written.
4141
* The answer's `hotLoaded` says whether the running kernel's
4242
* `manifest.register` resolved (#22695). An inline manifest whose
43-
* register throws is refused (`PLUGIN_REGISTER_FAILED`, 422); a
44-
* cloud-fetched one is still installed (the lenient path) and answers
45-
* `200` with `hotLoaded: false` and the register error's message as
46-
* `hotLoadError` — in the ledger, loaded at the next restart —
47-
* unless the registry refused it because another holder already
48-
* holds a name the package claims: its namespace (#31392), a
49-
* position, permission set or capability name (held by the
50-
* environment catalog, #25366, or by another package or a
51-
* built-in, #31392), or a bare name — a flow, a webhook, a
52-
* dataset, … (#31296; `bare-name-one-holder.ts` in
53-
* `@objectstack/objectql`). No restart clears any of these, so both
54-
* branches refuse them (`PLUGIN_REGISTER_FAILED`, 422) and nothing
55-
* is registered or written.
43+
* register throws is refused (`PLUGIN_REGISTER_FAILED`, 422), and
44+
* so is a cloud-fetched one whose register the engine REFUSED — a
45+
* throw declaring a 4xx status of its own (#31478): a name another
46+
* holder holds (its namespace, a position, permission set or
47+
* capability name, a bare name; #25366, #31296, #31392), a default
48+
* record page, an object another package owns, a picklist or a
49+
* bound action's object nobody declares, a body its schema refuses.
50+
* No restart heals a refusal, so nothing is registered or written.
51+
* A cloud-fetched register failure that declares no 4xx (the
52+
* runtime's own fault) takes the lenient path: still installed,
53+
* answering `200` with `hotLoaded: false` and the register error's
54+
* message as `hotLoadError` — in the ledger, loaded at the next
55+
* restart.
5656
*
5757
* `installMode: 'managed' | 'unmanaged'` (ADR-0131 D6; absent means
5858
* `managed`, the key parsed against `InstallModeSchema`) chooses
@@ -396,11 +396,6 @@ import {
396396
} from '@objectstack/metadata-protocol';
397397
import { resolveThrownHttpError } from '@objectstack/types';
398398
import { unknownQueryParamRefusalMessage } from '@objectstack/rest';
399-
// [#25366, #31296, #31392] The code the registry's namespace, security-catalog
400-
// and bare-name refusals share, compared with each class's `name` rather than
401-
// `instanceof`-tested: the engine keeps the classes unexported so a consumer
402-
// holding the other build realm's copy cannot misread them.
403-
import { NAMESPACE_CONFLICT_CODE } from '@objectstack/objectql';
404399

405400
/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */
406401
type SessionRead = Awaited<ReturnType<NonNullable<AuthSessionApi['getSession']>>>;
@@ -415,57 +410,46 @@ const ROUTE_BASE = '/api/v1/marketplace/install-local';
415410
export const SAMPLE_DATA_COUNT_QUERY_PARAMS: readonly string[] = Object.freeze([]);
416411

417412
/**
418-
* [#25366, #31296, #31392] The registry's refusals of a name another holder
419-
* already holds in a deployment-wide namespace, by the `name` each class gives
420-
* itself (`registry.ts` in `@objectstack/objectql`). The three share
421-
* {@link NAMESPACE_CONFLICT_CODE}; the engine keeps the classes unexported, so
422-
* a consumer reads the code plus the name, never `instanceof`.
413+
* [#31478] Whether a `manifest.register` failure is the engine REFUSING this
414+
* package — a throw that declares a 4xx status of its own
415+
* (`resolveThrownHttpError(err).declaredStatus`), never one this door
416+
* supplies. Every refusal the registry's package door and the manifest
417+
* service's seals raise declares one (`422`, ADR-0112): the held names
418+
* (#25366, #31296, #31392), the default record page, a misplaced default page,
419+
* an object another package owns, an object name two packages of one
420+
* artifact or one shared namespace define, a picklist or a bound action's
421+
* object no loaded package declares, a body its schema refuses. Each is judged
422+
* before the refused package's own writes stand, and each is a verdict on the
423+
* body against this environment, so it is the inline branch's answer on this
424+
* branch too. (A multi-package artifact registers its packages one by one: a
425+
* member refused after another registered leaves that earlier member in the
426+
* running kernel, unledgered, exactly as on the inline branch.) Read by the
427+
* declaration, never by a list of classes or codes, so a refusal the engine
428+
* gains later is refused here without a change.
423429
*
424-
* - `NamespaceConflictError` — the package's `manifest.namespace` is owned by
425-
* another installed package that is not a co-owner of it (ADR-0048 Phase 1);
426-
* - `SecurityCatalogNameConflictError` — a position, permission set or
427-
* capability name it ships is held by another package, the environment
428-
* catalog or a built-in (`security-catalog-namespace.ts`);
429-
* - `BareNameConflictError` — an item of a bare-name type (a flow, a webhook,
430-
* a dataset, …) is held by another package or the platform
431-
* (`bare-name-one-holder.ts`).
432-
*/
433-
const NAME_HELD_ELSEWHERE_REFUSALS: ReadonlySet<string> = new Set([
434-
'NamespaceConflictError',
435-
'SecurityCatalogNameConflictError',
436-
'BareNameConflictError',
437-
]);
438-
439-
/**
440-
* [#25366, #31296, #31392] Whether a `manifest.register` failure is one of the
441-
* registry's refusals of a name another holder already holds
442-
* ({@link NAME_HELD_ELSEWHERE_REFUSALS}).
443-
*
444-
* Each is the ONE pre-write check for its rule: the registry's package door
445-
* raises it ahead of every mutation it makes, for every door that installs a
446-
* package, so this door asks it rather than keeping a second reading of who
447-
* holds a name. Recognised by the error's own brand, never by its types or
448-
* holders: which types a rule covers and who holds a name are the registry's
449-
* to decide, so a type a rule gains later is refused here without a change.
450-
*
451-
* No restart heals any of them, so the cloud branch's lenient answer ("loads
452-
* at the next restart") would be false for each:
453-
* - an environment-held catalog name is loaded from `sys_metadata` at every
454-
* boot, and the next load of the package meets it again;
455-
* - a namespace or a catalog name another package holds, and a built-in
456-
* catalog name, are judged again at every load, with no seal: the next
457-
* boot meets the same holder and refuses the registration again;
458-
* - a bare name is the other direction: the ledger entry is rehydrated
459-
* BEFORE the cold boot seals the holders, so the boot ADMITS the clash with
460-
* one warning (`BARE_NAME_COLD_BOOT_MODE`), and every consumer resolving
461-
* the name is served one of the two packages' items by registration order.
430+
* No restart heals any of them, so the lenient answer ("loads at the next
431+
* restart") would be false. Measured on a real rehydrate (the `kernel:ready`
432+
* seals run before it, so a ledger entry is judged as a late artifact):
433+
* - a refusal of the body itself (a misplaced default page, two objects of
434+
* one name in one artifact, a body its schema refuses) meets the same body
435+
* at every boot;
436+
* - a refusal naming a holder (an owned object, a default record page, a
437+
* held name) meets the same holder at every boot;
438+
* - a picklist or bound-action object nobody declares is healed only by
439+
* ANOTHER install that brings it — after which installing this package
440+
* again succeeds at once — and at a restart only when the ledger happens
441+
* to list the bringer first: the rehydrate sorts by declared
442+
* `dependencies`, and the reference this refusal names is one the package
443+
* did not declare. Declared, it is the pre-install dependency check's
444+
* `422` instead.
462445
*
463-
* Under `OS_METADATA_COLLISION=warn` the registry downgrades the namespace gate
464-
* to a warning and throws nothing, so there is nothing here to recognise.
446+
* What stays lenient is a failure that declares NO 4xx — an undeclared throw,
447+
* or a declared 5xx: the runtime's own fault, not a verdict on the package,
448+
* which the restart (of this build or a fixed one) can heal.
465449
*/
466-
function refusesNameHeldElsewhere(err: unknown): boolean {
467-
const e = err as { code?: unknown; name?: unknown } | null;
468-
return e?.code === NAMESPACE_CONFLICT_CODE && typeof e.name === 'string' && NAME_HELD_ELSEWHERE_REFUSALS.has(e.name);
450+
function isDeclaredRegisterRefusal(err: unknown): boolean {
451+
const declared = resolveThrownHttpError(err).declaredStatus;
452+
return declared !== undefined && declared >= 400 && declared < 500;
469453
}
470454

471455
/**
@@ -2971,8 +2955,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
29712955
const registerError = String(err?.message ?? err);
29722956
// For offline file imports we treat a register failure as a hard
29732957
// failure (don't persist). Cloud installs historically tolerated
2974-
// this (the on-disk record survives a restart), so keep that path
2975-
// lenient for backwards compatibility.
2958+
// this (the on-disk record survives a restart), so that path stays
2959+
// lenient for a fault — never for a refusal (#31478, below).
29762960
// [#30885] Whatever the branch, the version that still serves is
29772961
// the installed one: the columns step 2f carried go back to it.
29782962
await reverseInstallDeclaredRenames(carried, reverseFailed);
@@ -2983,30 +2967,30 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
29832967
error: { code: 'PLUGIN_REGISTER_FAILED', message: `Failed to register imported manifest: ${registerError}` },
29842968
}, 422);
29852969
}
2986-
// [#25366, #31296, #31392] …EXCEPT the registry's refusals of a
2987-
// name another holder already holds: the package's namespace, a
2988-
// position, permission set or capability name, or a bare name (a
2989-
// flow, a webhook, a dataset, …), held by another package, the
2990-
// environment catalog or the platform. No restart heals any of them
2991-
// (see {@link refusesNameHeldElsewhere}), so the lenient answer
2992-
// ("loads at the next restart") would be false. Measured for the
2993-
// environment-held catalog name on a real restart: the boot runs,
2994-
// the rehydrate logs the refusal, and the package is not loaded at
2995-
// all — its earlier version neither, since the ledger entry that
2996-
// named it was overwritten. The registry's package door refused
2997-
// before any of its own writes, and nothing of this door's is
2998-
// written yet, so the cloud branch refuses exactly as the inline
2999-
// branch does — `422 PLUGIN_REGISTER_FAILED`, the registry's
3000-
// sentence naming both holders — and the installer renames and
3001-
// retries.
3002-
if (refusesNameHeldElsewhere(err)) {
2970+
// [#25366, #31296, #31392, #31478] …EXCEPT a refusal: a register
2971+
// failure that declares a 4xx status of its own is the engine's
2972+
// verdict on this body against this environment (see
2973+
// {@link isDeclaredRegisterRefusal}), and no restart heals it, so
2974+
// the lenient answer ("loads at the next restart") would be false.
2975+
// Measured on a real restart (#25366, #31478): the rehydrate logs
2976+
// the same refusal and the package is not loaded at all — its
2977+
// earlier version neither, since the ledger entry that named it
2978+
// was overwritten.
2979+
// The engine refused before any of its own writes stood, and
2980+
// nothing of this door's is written yet, so the cloud branch
2981+
// refuses exactly as the inline branch does — `422
2982+
// PLUGIN_REGISTER_FAILED` with the engine's sentence — and the
2983+
// installer corrects the body or the environment and retries.
2984+
if (isDeclaredRegisterRefusal(err)) {
30032985
return c.json({
30042986
success: false,
30052987
error: { code: 'PLUGIN_REGISTER_FAILED', message: `Package '${manifestId}' cannot be installed: ${registerError}` },
30062988
}, 422);
30072989
}
3008-
// [#22695] The lenient path stays lenient — whether it should is a
3009-
// separate decision, ⛔ not made here. What changes is that the
2990+
// [#22695, #31478] The lenient path is what remains: a register
2991+
// failure that declares no 4xx — an undeclared throw or a declared
2992+
// 5xx, the runtime's own fault rather than a verdict on the
2993+
// package — is ledgered and loads at the next restart, and the
30102994
// caller is TOLD: the answer carries `hotLoaded: false` and this
30112995
// message, so a reader can say "installed, loads at the next
30122996
// restart" instead of a false success.

0 commit comments

Comments
 (0)