Skip to content

fix(runtime): read-only package refusals name the paired .custom route (#29568) - #29587

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-j59c58
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-j59c58

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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 里讲托管安装后怎么定制的那一段也同步改了。
  • 为什么改:旧提示让人"按原名另存一份,作为环境级状态(ADR-0131 D6)"。自从 ADR-0146 D11 关掉了这条不带 package 的保存,照做会再吃一个 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

  • The sentence. CUSTOMISE_BESIDE_REMEDY (a constant) becomes customizeInPairRemedy(id) in packages/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.
  • The sites. These are the same two callers as before: requireWritablePackage (delete, disable, publish, revert, adopt-orphans, the manifest PATCH, an install with overwrite) and requireDuplicableSource (duplicate). The status, code, details, gate order and predicate are unchanged.
  • The doc blocks. The block above the function, the requireWritablePackage history 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.
  • The docs. 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, the overrides declaration, the types that take no override, and the 403 NOT_OVERRIDABLE on a package-less save. ADRs and CHANGELOGs are left as history.
  • Not touched. new-similar-target.ts already 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.tsx holds 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)
Door Status / code Message length
DELETE /api/v1/packages/com.example.showcase 422 WRITABLE_PACKAGE_REQUIRED 544
PATCH …/disable 422 WRITABLE_PACKAGE_REQUIRED 546
POST …/publish 422 WRITABLE_PACKAGE_REQUIRED 546
POST …/revert 422 WRITABLE_PACKAGE_REQUIRED 544
POST …/adopt-orphans 422 WRITABLE_PACKAGE_REQUIRED 564
PATCH /api/v1/packages/com.example.showcase 422 WRITABLE_PACKAGE_REQUIRED 540
POST /api/v1/packages (overwrite: true) 422 WRITABLE_PACKAGE_REQUIRED 550
POST …/duplicate 422 DUPLICATE_SOURCE_NOT_A_BASE 815

Every 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, code and details (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 into com.example.showcase.custom with overrides 200) serves the saved columns at showcase_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) answered 403 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
Base POST …/ensure-customization Save into the pair (?package=BASE.custom)
com.example.showcase (code-booted app, namespace showcase) 201 view override 200, served at the base's name with the saved columns; flow override 200; a new view under its own name 200; action override 403 NOT_OVERRIDABLE ("not yet available", ADR-0146 D9); permission override 403 NOT_OVERRIDABLE ("takes no override", ADR-0146 D6, "new similar")
com.acme.installed (installed through POST /api/v1/marketplace/install-local, managed, namespace acme) 201 view override 200, dashboard override 200, both served at the base's name
com.objectstack.setup (platform, namespace setup) 201 app/setup 403 ITEM_LOCKED (vendor lock full)
com.objectstack.plugin-auth (platform, namespace sys) 201 not measured (ships no overridable item to try)
com.objectstack.audit (platform, the second sys namespace) 422 NAMESPACE_CONFLICT, naming com.objectstack.plugin-auth.custom none
com.objectstack.platform-objects (platform, no namespace) 409 RESOURCE_CONFLICT, "declares no namespace" none

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

  • New: packages/runtime/src/domains/packages-readonly-remedy-pair-route.test.ts (16). At each of the seven WRITABLE_PACKAGE_REQUIRED doors, 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.
  • New: packages/runtime/src/package-readonly-remedy-follow.integration.test.ts (1). It boots createStandaloneStack over an artifact (a code-registered app with a dashboard) and drives the HTTP dispatcher's doors. DELETE answers 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-less PUT answers 403 NOT_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.
  • Forced edits: packages/runtime/src/domains/packages-readonly-gate.test.ts, two pins that asserted toContain('ADR-0131 D6') (the duplicate envelope case and the POST /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 of ADR-0131 D6. Their other assertions are unchanged.
  • Ablation (standard order item 5). The old sentence went back into customizeInPairRemedy through scripts/ablation-replace.mjs (anchor 1 → 0, blob 5a50e54 → 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 HEAD empty. Runtime tests run from src, so no build or dist leg was needed.
  • Consumer suites. No packages/rest test or dogfood test asserts this message text (git grep of the old phrases and of WRITABLE_PACKAGE_REQUIRED message assertions over packages/rest, packages/qa: none; the one dogfood hit matches code only). 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 lint scoped to the touched package (eslint packages/runtime --no-inline-config, the root config): exit 0. content/docs has 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 full turbo run build of ./packages/** and ./examples/**, all exit 0. --ran reconciliation: "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. After git fetch origin 621a4876… it answered exit 0 (48 cases).
  • Ablation and the live base/head measurements are under Tests and Why.

Acceptance notes

  • ui/packages/app-shell/src/components/RefusalMessage.test.tsx keeps the old ADR-0131 D6 sentence as one of three "measured on Setup" fixtures for splitRefusal. 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).
  • The messages are longer now: 747–771 characters at the seven WRITABLE_PACKAGE_REQUIRED doors, 1,022 at duplicate (measured on head). The /packages doors 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

#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>
@github-actions github-actions Bot added the size/m label Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 6 documentable anchor(s).

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/environment-routing.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/automation/connectors.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/deployment/app-dependencies.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/deployment/install-modes.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/deployment/marketplace-handbook.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/frontend/plugins/plugin-chatbot.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/getting-started/examples.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/permissions/permission-sets.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy), /api/v1/packages/:id/ensure-customization (route, a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/ui/apps.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))

⛔ 7 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/releases/v17/17-4.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/releases/v17/17-5.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/releases/v17/17-7.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/releases/v18/18-0.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/releases/v18/18-1.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))
  • content/docs/releases/v18/18-2.mdx (via /api/v1/packages (route, a path literal in customizeInPairRemedy))

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.

What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d3175d59d036d3c2819d5e7913c8c406c1ea634d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 756db9fb7dea7d41b73d6cb9a1db96c8f67bbf3a — the merge of head c34c2e14a8447260e8ab5fb959fd3104776e8c0b into base d3175d59d036d3c2819d5e7913c8c406c1ea634d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d3175d59d036d3c2819d5e7913c8c406c1ea634d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head c34c2e1 · 2026-11-02T20:19Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract review

Served-tier: TIER_DEFAULT
Head-sha: c34c2e14a8447260e8ab5fb959fd3104776e8c0b
Local-runs: none

Inputs: card #29568 (body, triage 6585925375, claims 6586570416 / 6586610006, dev report 6587487066), the PR body, its 6-file list, the net diff against main, and the check-runs on the head. The cited code was read on origin/main (d3175d59); nothing was built or run.

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

  1. Accept-set: unchanged. Right. The diff swaps the CUSTOMISE_BESIDE_REMEDY constant for customizeInPairRemedy(id) at the same two concatenation sites (requireWritablePackage, requireDuplicableSource). Status 422, code (WRITABLE_PACKAGE_REQUIRED / DUPLICATE_SOURCE_NOT_A_BASE), details (packageId, docs), the predicate (isWritablePackage) and the call order at all 8 doors are untouched in the hunks. No new code, no packages/spec/**, no metadata-protocol edit. Clause-②: no holds.

  2. Public surface: the message text of 8 refusals, and nothing parses it. Right. git grep on main finds no reader of this text in packages/rest, packages/client, packages/qa, apps or ui. Clients branch on code. The one text reader is ui splitRefusal (ui/packages/app-shell/src/utils/refusalLead.ts). It leads with the first ./!/? before whitespace, after stripping [writable_package_required]. That sentence ("Cannot … : it is read-only (provided by code or an installed app).") is byte-identical on head. The new sentence's ; is not followed by [, so the joined-issues split cannot fire either. The pair id's dots (com.x.custom') are never followed by whitespace, so they do not end a sentence. The lead and full behave as before.

  3. The sentence is true for every base kind measured. Right. I checked each Step 0 row against the clause that covers it:

    • code app (com.example.showcase) and installed managed (com.acme.installed): ensure 201, declared save 200, served. The main clause covers both.
    • system com.objectstack.setup: ensure 201, app/setup 403 ITEM_LOCKED. Covered by "a locked item, is refused naming … the lock".
    • action 403 declared_type_not_yet_available and permission 403 declared_type_not_overridable: main's protocol-packaged-base-refusal.ts messages end "rename the item" / "create your own … ("new similar")". So "refused naming its route" holds.
    • second sys namespace 422 NAMESPACE_CONFLICT and no namespace 409: covered by "a package that cannot have a pair is refused at that door, naming why".
    • id the pairing rule refuses (customizationPackageId not ok): the sentence names the ensure door only and no pair id. Pinned in the new test.

    It never promises a route that refuses the caller. All 8 doors run requireManageMetadata then refuseWalledCatalogPackageWrite before the read-only gate (main packages.ts, every site). The ensure door runs the same two gates. So anyone who receives the 422 is admitted at the ensure door on caller grounds.

  4. The save-contract spelling matches main. Right. closedOverlayWriteClauses (packages/metadata-protocol/src/protocol-packaged-base-refusal.ts) spells the pair 'PAIR' (?package=PAIR) and the declaration overrides: { package: 'BASE', basedOn }. The new sentence uses the same tokens. ADR-0146 D2 puts the declaration in the item body with package = the base id and basedOn = the base's manifest.version. D3 makes the ensure door the pair's one creation path. D11 closes the package-less write 403 NOT_OVERRIDABLE. The integration test reads the three tokens out of a live 422 and follows them (ensure 201, PUT ?package=PAIR with overrides 200, served label changed). It also has the package-less control (403 naming the pair).

  5. Length: no cap applies, and none truncates. Right. deps.error → HttpDispatcher#error → apiErrorResponse (packages/runtime/src/error-envelope.ts) does no slicing. CLIENT_MESSAGE_MAX (500, @objectstack/types truncateClientMessage) is reached only through mapDataError / resolveErrorResponse, not through these dispatcher doors. packages/rest's direct mount for packages is POST /packages/publish alone. The dev's base measurement shows the duplicate door already answering 815 characters whole on main. The fix-first ordering that the save door's doc block asks for is about that 500 bound, so it does not bind on these doors. The new sentence is one fix clause followed by one limits clause, in the house shape.

  6. Docs (install-modes.mdx): accurate against ADR-0146 D2/D3/D6/D9/D11. Right. The link target heading exists on main (metadata-lifecycle.mdx "Customizing an installed package: the paired .custom override (ADR-0146)"), and Check Documentation Links is green.

  7. Overlap with main: none. Right. The merge-base is dbca3ef0 and origin/main is 9 commits ahead. None of those commits touches the 6 PR paths. The two adjacent ones are fix(spec): the packageless-overlay-write-closed upgrade entry names both closed overlay write addresses #29535 (migration entry 18's prose, packages/spec) and An app cannot make its own sys_user record page the one a person opens to: on 18.x the console opens plugin-auth's sys_user_detail, and no declarable route reaches a second record page #28979 (defaultRecordPageSaveRefusal in protocol-save-door-refusals.ts). Neither changes closedOverlayWriteClauses, customizationPackageId or the ensure door. The PR does not need a merge.

② Semver level

.changeset/29568-readonly-remedy-pair-route.md: '@objectstack/runtime': patch, with Clause-②: no (message text only) and Metadata-protocol: none on line-initial lines, the same in the PR body. Right. The diff changes the message text of a released package and nothing an author can write. The other files are tests and content/docs, which publish nothing. Check Changeset is green.

③ Boundary flags

  • open_questions[0], the ui RefusalMessage.test.tsx fixture: answer B, and A as written is not available. 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 closed as completed via fix(studio): the landing page names the .custom customization route, not the environment overlay #29537 before this card was triaged, so it cannot carry the refresh. Leave the fixture as it is for this PR. It pins splitRefusal's lead only, the lead is byte-identical on head, and Test UI is green. It was never a verbatim copy: it cuts the old sentence at "save an item under its own name." while the server's text went on. A refresh is optional ui-lane hygiene for the seat to carry. It does not block this PR and is not filed.
  • Deviation: not merged with main (9 behind). Verified, with no overlap (① 7). Accepted.
  • Deviation: harness defaults not followed (no assignee, reviewer or subscription). This follows the standard order. Accepted.
  • Deviation: the integration file's 17 s duration. The cost is import (createStandaloneStack), the tests take 1.24 s, and the workspace deps are pre-imported at module load as check-test-source-alias requires. The file declares no budget. Accepted.
  • Gate note: check-plugin-teardown-shape --self-test exited 3 before the fixture fetch. That is a clone prerequisite, not a red, and CI's lint legs on the head carry the verdict. Accepted.
  • out_of_scope_findings[0]: the same fixture as above, with the same answer.

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: claude/project-thread-j59c58
Reviewed-by: session_01ADHkmQjPiXgNoSaEpqeGvY

VERDICT: PASS

Stamped 2026-11-02T20:35Z.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

席位核对 · ACCEPT · domain:server seat 1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY · 2026-11-02T20:41Z

Head c34c2e14a8, report 6587487066 (on #29568), claim on #29568 (branch claude/project-thread-j59c58). Clause-②: no → declaration face, reviewed at TIER_DEFAULT: record 6587839294 on this head, PASS, no must-fix.

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.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

packages: the 422 WRITABLE_PACKAGE_REQUIRED remedy still tells the caller to save an item under its own name, the package-less save ADR-0146 D11 closed

1 participant