Repository navigation
Commit 645ed3d
<!-- 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
- .changeset
- packages/cloud-connection/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
40 | 40 | | |
41 | 41 | | |
42 | 42 | | |
43 | | - | |
44 | | - | |
45 | | - | |
46 | | - | |
47 | | - | |
48 | | - | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | | - | |
53 | | - | |
54 | | - | |
55 | | - | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
56 | 56 | | |
57 | 57 | | |
58 | 58 | | |
| |||
396 | 396 | | |
397 | 397 | | |
398 | 398 | | |
399 | | - | |
400 | | - | |
401 | | - | |
402 | | - | |
403 | | - | |
404 | 399 | | |
405 | 400 | | |
406 | 401 | | |
| |||
415 | 410 | | |
416 | 411 | | |
417 | 412 | | |
418 | | - | |
419 | | - | |
420 | | - | |
421 | | - | |
422 | | - | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
423 | 429 | | |
424 | | - | |
425 | | - | |
426 | | - | |
427 | | - | |
428 | | - | |
429 | | - | |
430 | | - | |
431 | | - | |
432 | | - | |
433 | | - | |
434 | | - | |
435 | | - | |
436 | | - | |
437 | | - | |
438 | | - | |
439 | | - | |
440 | | - | |
441 | | - | |
442 | | - | |
443 | | - | |
444 | | - | |
445 | | - | |
446 | | - | |
447 | | - | |
448 | | - | |
449 | | - | |
450 | | - | |
451 | | - | |
452 | | - | |
453 | | - | |
454 | | - | |
455 | | - | |
456 | | - | |
457 | | - | |
458 | | - | |
459 | | - | |
460 | | - | |
461 | | - | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
462 | 445 | | |
463 | | - | |
464 | | - | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
465 | 449 | | |
466 | | - | |
467 | | - | |
468 | | - | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
469 | 453 | | |
470 | 454 | | |
471 | 455 | | |
| |||
2971 | 2955 | | |
2972 | 2956 | | |
2973 | 2957 | | |
2974 | | - | |
2975 | | - | |
| 2958 | + | |
| 2959 | + | |
2976 | 2960 | | |
2977 | 2961 | | |
2978 | 2962 | | |
| |||
2983 | 2967 | | |
2984 | 2968 | | |
2985 | 2969 | | |
2986 | | - | |
2987 | | - | |
2988 | | - | |
2989 | | - | |
2990 | | - | |
2991 | | - | |
2992 | | - | |
2993 | | - | |
2994 | | - | |
2995 | | - | |
2996 | | - | |
2997 | | - | |
2998 | | - | |
2999 | | - | |
3000 | | - | |
3001 | | - | |
3002 | | - | |
| 2970 | + | |
| 2971 | + | |
| 2972 | + | |
| 2973 | + | |
| 2974 | + | |
| 2975 | + | |
| 2976 | + | |
| 2977 | + | |
| 2978 | + | |
| 2979 | + | |
| 2980 | + | |
| 2981 | + | |
| 2982 | + | |
| 2983 | + | |
| 2984 | + | |
3003 | 2985 | | |
3004 | 2986 | | |
3005 | 2987 | | |
3006 | 2988 | | |
3007 | 2989 | | |
3008 | | - | |
3009 | | - | |
| 2990 | + | |
| 2991 | + | |
| 2992 | + | |
| 2993 | + | |
3010 | 2994 | | |
3011 | 2995 | | |
3012 | 2996 | | |
| |||
0 commit comments