Repository navigation
fix(runtime): read-only package refusals name the paired .custom route (#29568) - #29587
Conversation
#29568) The 422 WRITABLE_PACKAGE_REQUIRED and DUPLICATE_SOURCE_NOT_A_BASE remedies told the caller to save an item under its own name as environment-level state (ADR-0131 D6), the package-less save ADR-0146 D11 closed with 403 NOT_OVERRIDABLE. They now name the ensure door and the pair, with the overrides declaration. Message text only: same status, code and doors. Claude-Session: https://claude.ai/code/session_01ADHkmQBCiBBtvq68QfwPBe Co-authored-by: Claude <noreply@anthropic.com>
…29568) Claude-Session: https://claude.ai/code/session_01ADHkmQBCiBBtvq68QfwPBe Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 28 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 756db9fb7dea7d41b73d6cb9a1db96c8f67bbf3a && git checkout 756db9fb7dea7d41b73d6cb9a1db96c8f67bbf3a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d3175d59d036d3c2819d5e7913c8c406c1ea634d c34c2e14a8447260e8ab5fb959fd3104776e8c0b && git checkout -B drift-repro d3175d59d036d3c2819d5e7913c8c406c1ea634d && git merge --no-ff c34c2e14a8447260e8ab5fb959fd3104776e8c0b
node scripts/docs-audit/affected-docs.mjs --json d3175d59d036d3c2819d5e7913c8c406c1ea634d
|
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head c34c2e1 · 2026-11-02T20:19Z |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract reviewServed-tier: Inputs: card #29568 (body, triage 6585925375, claims 6586570416 / 6586610006, dev report 6587487066), the PR body, its 6-file list, the net diff against CI: all 50 check-runs on the head have completed: 38 success, 12 skipped (opt-in or not-triggered legs), 0 red. That includes Test Core 1–8/8, Type Check workspace and consumer gates, Test UI, Lint ESLint, PM dispatch-gates, Check Changeset, Governed Surface Queue Guard and Dogfood Regression Gate. ① Derived judgments
② Semver level
③ Boundary flags
Must-fix: none. Should-fix:
Nit (optional): in "— delete a package you own, or customize what it ships in its paired package …", "it" can be read as the caller's own package. The pair id and the ensure route make the meaning clear, so this is a nit only (the old text said "this package"). Implemented-by: VERDICT: PASS Stamped 2026-11-02T20:35Z. |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY 席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | Draft, base main, Fixes #29568. 6 files: customizeInPairRemedy(id) in packages/runtime/src/domains/packages.ts (replacing CUSTOMISE_BESIDE_REMEDY) and its doc blocks, content/docs/deployment/install-modes.mdx, two unit test files, one integration test, and an @objectstack/runtime patch changeset (Clause-②: no, Metadata-protocol: none). No packages/spec/**, no new code, protocol.ts untouched. Not governed (check-governed-merges governed: false). |
| Rule | Message text only. All 8 doors keep their status, code, details, predicate and order (re-measured live on head). The remedy now names POST /api/v1/packages/<id>/ensure-customization, the save into <id>.custom declaring overrides: { package, basedOn }, and what refuses: a type with no override or a locked item, naming its route or the lock, and a base with no pair, refused at the ensure door naming why. Step 0 measured each of these on six base kinds. |
| Open question | The ui RefusalMessage.test.tsx fixture keeps the old sentence: B, leave it, as the review rules. It tests only the lead sentence, which is byte-identical, and #29489 is closed. The PR body's carrier sentence is corrected (the review's should-fix, applied by the seat as a body-only edit; the head is unchanged). |
| Evidence | 16 pins of the whole sentence plus one integration test that follows the remedy end to end (403 on the package-less control, then ensure 201, save 200, served). Ablation with the old sentence: 20 red, restored. runtime 527/527 files, typecheck and lint 0, gates 90/90. CI on this head: 54 runs, 40 green, 14 skipped, none red. check-expected-skips OK (14, all in main's roster). |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay. |
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY
Requested by Steve · project thread
Fixes #29568
Clause-②: no (message text only)
Metadata-protocol: none
Measured on base and head: the same requests get the same status,
code,details, doors and order. Only the message text changed (per-door table under Why).维护者速读
/packages写入口被拒时(422 WRITABLE_PACKAGE_REQUIRED共 7 个入口,加上 duplicate 的422 DUPLICATE_SOURCE_NOT_A_BASE),提示语的补救办法换成 ADR-0146 的配对包路线:先POST /api/v1/packages/ID/ensure-customization,再把条目存进ID.custom(?package=ID.custom),要替换包里自带的条目就声明overrides: { package: 'ID', basedOn }。install-modes.mdx里讲托管安装后怎么定制的那一段也同步改了。403 NOT_OVERRIDABLE,等于把人指进死胡同。code、details、哪些入口拒绝、拒绝顺序都不变,读code的客户端看不出差别。消息变长了(实测 747 到 771 字符,duplicate 1022 字符),这些入口不截断,实测整句返回。回滚就是 revert 本 PR。packages/runtime/src/domains/packages.ts的customizeInPairRemedy;packages/runtime/src/package-readonly-remedy-follow.integration.test.ts;content/docs/deployment/install-modes.mdx的 "What a managed install keeps" 一段。pnpm --filter @objectstack/runtime exec vitest run --project local src/domains/packages-readonly-remedy-pair-route.test.ts src/package-readonly-remedy-follow.integration.test.ts,预期 17 个全绿(16 个逐入口钉住新句子,1 个按新句子走通 ensure 加存入配对包,并验证这一条被服务出来)。What changes
CUSTOMISE_BESIDE_REMEDY(a constant) becomescustomizeInPairRemedy(id)inpackages/runtime/src/domains/packages.ts, because the route names the package. For a package whose pair the pairing rule derives (customizationPackageId,@objectstack/spec/kernel), the sentence reads: "customize what it ships in its paired package 'ID.custom' (ADR-0146 D3): ensure it with POST /api/v1/packages/ID/ensure-customization, then save the item there (?package=ID.custom), declaring overrides: { package: 'ID', basedOn } to replace an item 'ID' ships. A type that takes no override, or a locked item, is refused naming its route or the lock; a package that cannot have a pair is refused at that door, naming why." The pair address and the declaration are spelled exactly as the save door's own close spells them (packagelessOverlayWriteRefusal,@objectstack/metadata-protocol). For an id the rule derives no pair for, the sentence names the ensure door alone, and that door answers with the reason.requireWritablePackage(delete, disable, publish, revert, adopt-orphans, the manifestPATCH, an install withoverwrite) andrequireDuplicableSource(duplicate). The status,code,details, gate order and predicate are unchanged.requireWritablePackagehistory paragraph and the duplicate door's "two built answers" paragraph now name the pair, and the retired ADR-0131 D6 remedy is recorded as history.content/docs/deployment/install-modes.mdx, "What a managed install keeps", described the live refusal with the old model ("overlaid by saving one under the same name", "cloned under a new name"). It now describes the pair: the ensure door, a save naming the pair, theoverridesdeclaration, the types that take no override, and the403 NOT_OVERRIDABLEon a package-less save. ADRs and CHANGELOGs are left as history.new-similar-target.tsalready names the pair (ADR-0146 D3). The Studio landing copy was Studio landing: the description and read-only hint still say to customize a read-only package "with an environment overlay", which ADR-0146 closed #29489's (closed via fix(studio): the landing page names the .custom customization route, not the environment overlay #29537 before this card was triaged), and nothing there is touched.ui/packages/app-shell/src/components/RefusalMessage.test.tsxholds the old sentence as a frozen fixture. That fixture only tests how a refusal's lead sentence is split, it is outside this card's file surface, and it is listed under Acceptance notes.Why
The old remedy is a dead end, and the new route works for every kind of read-only base it promises. Measured on
main@dbca3ef0, showcase booted--fresh, an admin session, through the real routes:Refusal sites on main (base)
DELETE /api/v1/packages/com.example.showcaseWRITABLE_PACKAGE_REQUIREDPATCH …/disableWRITABLE_PACKAGE_REQUIREDPOST …/publishWRITABLE_PACKAGE_REQUIREDPOST …/revertWRITABLE_PACKAGE_REQUIREDPOST …/adopt-orphansWRITABLE_PACKAGE_REQUIREDPATCH /api/v1/packages/com.example.showcaseWRITABLE_PACKAGE_REQUIREDPOST /api/v1/packages(overwrite: true)WRITABLE_PACKAGE_REQUIREDPOST …/duplicateDUPLICATE_SOURCE_NOT_A_BASEEvery message ended with the ADR-0131 D6 sentence, untruncated.
On head (the same boot, the built branch): the same eight doors answer the same status,
codeanddetails(packageId,docs). The messages are 751 / 753 / 753 / 751 / 771 / 747 / 757 and 1,022 characters, each ending with the new sentence. Following it (ensure 201, the view saved intocom.example.showcase.customwithoverrides200) serves the saved columns atshowcase_task.in_progress.The old remedy, followed: refused
PUT /api/v1/meta/view/showcase_task.in_progress(no?package, the shipped body with a new label) answered403 NOT_OVERRIDABLE: "'view/showcase_task.in_progress' ships in 'com.example.showcase': save it into 'com.example.showcase.custom' (?package=com.example.showcase.custom), declaring overrides: { package: 'com.example.showcase', basedOn }. …". An installed app's view and dashboard got the same answer, naming that app's pair.The new route, followed, per kind of read-only base
POST …/ensure-customization?package=BASE.custom)com.example.showcase(code-booted app, namespaceshowcase)NOT_OVERRIDABLE("not yet available", ADR-0146 D9); permission override 403NOT_OVERRIDABLE("takes no override", ADR-0146 D6, "new similar")com.acme.installed(installed throughPOST /api/v1/marketplace/install-local, managed, namespaceacme)com.objectstack.setup(platform, namespacesetup)app/setup403ITEM_LOCKED(vendor lockfull)com.objectstack.plugin-auth(platform, namespacesys)com.objectstack.audit(platform, the secondsysnamespace)NAMESPACE_CONFLICT, namingcom.objectstack.plugin-auth.customcom.objectstack.platform-objects(platform, no namespace)RESOURCE_CONFLICT, "declares no namespace"So the sentence promises the route, and it names the two ways the route can stop: the save door refuses a type with no override (or a locked item) naming its own route, and the ensure door refuses a base it cannot pair, naming why. It promises nothing beyond what was measured.
Tests
packages/runtime/src/domains/packages-readonly-remedy-pair-route.test.ts(16). At each of the sevenWRITABLE_PACKAGE_REQUIREDdoors, for a code-booted and a platform-scoped package, it checks the status, the code and the message prefix, and that the message ENDS with the whole new sentence, spelled out in the test. The duplicate door carries the whole sentence between its other two remedies, and the protocol is never called. The unpairable-id branch is covered too. Every case also asserts that the three phrases only the old sentence carried (under its own name,environment-level state,ADR-0131 D6) are absent. The new sentence contains none of them, so the old sentence cannot pass these checks.packages/runtime/src/package-readonly-remedy-follow.integration.test.ts(1). It bootscreateStandaloneStackover an artifact (a code-registered app with a dashboard) and drives the HTTP dispatcher's doors.DELETEanswers 422 and the test reads the ensure route, the pair and the declared package out of the message. The control is the old remedy: a package-lessPUTanswers 403NOT_OVERRIDABLE, names the pair, and nothing is served. Then it calls the ensure door the message names (201) and the declared save into the pair (200), and the item's name now serves the saved label.packages/runtime/src/domains/packages-readonly-gate.test.ts, two pins that assertedtoContain('ADR-0131 D6')(the duplicate envelope case and thePOST /packages/:id/duplicate对一个启动中的代码包返回success:false, copiedCount:0, failedCount:0(空 base)—— ADR-0070 D4「复制成可写 base」对代码包是否本就该拷贝其对象? #14451 "no dead end" case for DELETE and disable). They now assert the ensure route and the absence ofADR-0131 D6. Their other assertions are unchanged.customizeInPairRemedythroughscripts/ablation-replace.mjs(anchor 1 → 0, blob5a50e54→e1e6091), and three files ran: 20 failed, 22 passed. All 16 new pins, the integration test (expected { ensureRoute: undefined, …(2) } to deeply equal { …(3) }) and the three forced pins went red. Restored: blob == HEAD,git diff HEADempty. Runtime tests run fromsrc, so no build or dist leg was needed.packages/resttest or dogfood test asserts this message text (git grepof the old phrases and ofWRITABLE_PACKAGE_REQUIREDmessage assertions overpackages/rest,packages/qa: none; the one dogfood hit matchescodeonly). So no rest or dogfood edit is forced. Dogfood is not owed: no!in the title and no(narrowing)arm.Verification
All on head
c34c2e14, in a card container:pnpm --filter @objectstack/runtime test(under the verify lock): Test Files 527 passed (527) · Tests 8503 passed | 19 skipped (8522), exit 0.pnpm --filter @objectstack/runtime typecheck(tsc --noEmit && pnpm check:test-typecheck): exit 0.pnpm lintscoped to the touched package (eslint packages/runtime --no-inline-config, the root config): exit 0.content/docshas no lint script, and the docs gates are in the list below.node scripts/pm/dispatch-gates.mjs --commands: 90 derived gates, each run after a fullturbo run buildof./packages/**and./examples/**, all exit 0.--ranreconciliation: "90 derived famil(ies) accounted for — 90 run, 0 NOT-MEASURED". One gate,check-plugin-teardown-shape.mjs --self-test, first answered exit 3 because its pinned fixture commit was missing from this clone ("cannot read the positive control at 621a487…"). That is a prerequisite, not a red. Aftergit fetch origin 621a4876…it answered exit 0 (48 cases).Acceptance notes
ui/packages/app-shell/src/components/RefusalMessage.test.tsxkeeps the old ADR-0131 D6 sentence as one of three "measured on Setup" fixtures forsplitRefusal. It asserts only the lead sentence, which is unchanged ("Cannot publish package …: it is read-only (provided by code or an installed app)."), so it stays green and true about the split. The fixture text is now stale. Refreshing it is optional ui-lane tidying, left with the seat (Studio landing: the description and read-only hint still say to customize a read-only package "with an environment overlay", which ADR-0146 closed #29489 is closed, so it carries nothing; contract review 6587839294 answered B, leave it).WRITABLE_PACKAGE_REQUIREDdoors, 1,022 at duplicate (measured on head). The/packagesdoors return the whole message, with no 500-character bound on this path. This was already the case on main, where duplicate answered 815 characters whole.Generated by Claude Code