Skip to content

fix(cloud-connection,cli): install a .custom export into the target's writable pair - #30207

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-j915rv
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-j915rv

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

Fixes #30106

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:一个环境导出的 .custom 定制包,装到另一个环境时,现在写进目标环境自己的可写配对包:先走配对包的 ensure 门,再逐项走 Studio 编辑用的保存门。不再当成代码注册。
  • 为什么改:以前安装报成功,但目标环境一条覆盖都没生效,定制读数为 0,配对包还变成只读,管理员在目标环境再也改不了。修复前在 dev 上实测复现。
  • 风险与回滚:只影响 id 以 .custom 结尾的配对包安装。某项保存被拒时,本次新建的配对包整包删掉;原有的配对包保留,答复列出写了和没写的项。回滚就是 revert 本 PR。
  • 先看:packages/cloud-connection/src/marketplace-install-local-plugin.ts(installCustomizationPair 与 pairInstallWriteFailure);packages/cloud-connection/src/marketplace-install-local-customization-pair.ts(哪些键算条目、写入顺序);packages/cli/src/commands/package/install.ts(输出)。
  • 两分钟验证:pnpm --filter @objectstack/cloud-connection exec vitest run src/marketplace-install-local-customization-pair-install.test.ts,预期 15 条全绿。
  • 需要你做的:无,席位落地。

What changes

POST /api/v1/marketplace/install-local now installs a base's paired customization package (BASE.custom, what GET /packages/BASE.custom/export writes) as what ADR-0146 D2 / D3 say it is: an ordinary writable package whose items are stored rows.

  • The pair install (installCustomizationPair, marketplace-install-local-plugin.ts). The steps run in this order, and nothing is written before the last one:
    1. The body is converted forward and walked into its items (planCustomizationPairInstall, new pure module marketplace-install-local-customization-pair.ts).
    2. The managed install's override write gate (2d) and field-patch target check (2e) run, with the same answers as before.
    3. The pair manifest must declare what the ensure door writes (customizationPairManifestRefusal). A pair already read-only here is refused 409 before the ensure door is asked.
    4. The ensure door answers the writable pair, and creates it when absent.
    5. Each item is written through saveMetaItem, in registry load order, with mode: 'publish', the pair as its package, and the installer as actor.
      Nothing is registered as code, schema-synced, or written to the cached-manifest directory. The answer carries customizationPair (basePackageId, packageId, created, writable), writtenCount and written.
  • What the body may carry. It may carry the collections (PLURAL_TO_SINGULAR), viewItems and objectExtensions (as object_extension items named by their target), plus the package's own keys. A key with no item form is refused 422 and every problem is listed. That covers code, a translations bundle, an envelope, and a key the install-key table does not classify. A nameless item, and two items with one identity, are refused the same way. An email_template is keyed by (name, locale), as the save door keys its row. Seed data is reported as not loaded.
  • A save refused part-way answers failure with the save's own code and status, naming the item and its path. The refusal is logged at error with the save's own words. A pair this install created is deleted again; if that deletion does not finish, the plugin logs at error. A pair that existed before is kept, and the answer lists what was and was not written.
  • A pair read-only on the target is refused 409 with a remedy that depends on why it is read-only:
    • ledgered as code by an earlier install: uninstall, restart, install again;
    • uninstalled but still registered until a restart: restart;
    • provided by code or an installed app: remove it first.
  • A pair inside a multi-package artifact is refused 422 (details.pairs): a pair is installed on its own, after its base.
  • The CLI (os package install) prints the pair it wrote into and the item count. A runtime that predates this answers a pair as a managed install. The CLI now prints that answer as an error with the way back and exits 1, instead of printing success.

Tests

  • New: marketplace-install-local-customization-pair.test.ts, the planner (7 cases).
  • New: marketplace-install-local-customization-pair-install.test.ts, the door at its route (15 cases):
    • writes through the save door, with no register, sync or ledger;
    • an existing pair answers created: false;
    • seed data is reported as not loaded;
    • rollback of a pair the install created; a pre-existing pair is kept; a failed rollback is logged;
    • the three read-only refusals are asked before the ensure door;
    • the ensure door's refusal is relayed;
    • body problems, a manifest the ensure door would not write, a pair inside a bundle, and a missing door (503) are all refused before any write.
  • New: packages/cli/test/package-install-customization-pair.test.ts (2 cases):
    • the pair success output;
    • an older runtime's as-code answer prints an error and exits 1.
  • Updated: marketplace-install-local-override-gate.test.ts (3 cases) and marketplace-install-local-extension-field-suffix.test.ts (1 case) pinned a pair registered as code (registered === [PAIR]). They now pin it written into its pair (registered === [], saves named). Their harness gains the pair's doors, and the base declares the namespace its pair's is derived from.
  • Updated: packages/cli/test/package-export.test.ts (1 case). Its round-trip stub now answers the pair entry in the pair shape.
  • @objectstack/cloud-connection full suite at 6be0aea6a: Test Files 70 passed (70), Tests 846 passed (846).
  • @objectstack/cli unit project (vitest run --project unit): 382 of 383 files passed, and 5381 of 5382 tests. The one failure was test/package-export.test.ts, whose round-trip stub answered the exported pair as code. It now answers the pair shape, and the file passes 6/6. The integration tier is left to CI.
  • Dogfood, unchanged files: package-export-reinstall-custom-pair and package-export-bundle-round-trip (2 files, 16 tests) pass at 6be0aea6a. The second installs a pair entry on a fresh target and re-exports it.

Acceptance notes

  • Clause-② re-check, measured. This PR copies the claim's Clause-②: no. On measurement, two facts may read as an arm, and they are left to the seat:
    • (narrowing) a pair-id body carrying a key with no item form, or a pair inside a multi-package artifact, is now refused, where before it was registered as code;
    • (widening) a pair install's answer gains customizationPair, writtenCount and written, and drops hotLoaded and storageDir.
  • Cached-manifest location. A managed install's cache is LocalManifestSource's default, CWD/.objectstack/installed-packages, and is not keyed by OS_HOME. That breaks os dev --fresh isolation: a ledgered entry rehydrates under a new OS_HOME. A pair no longer writes one at all. Moving the directory for other packages needs a read-old-location fallback, so this PR leaves it. Measured: the pre-fix install wrote examples/app-showcase/.objectstack/installed-packages/com.example.showcase.custom.json under a fresh OS_HOME, and a fresh boot rehydrated an entry placed there.
  • After DELETE /api/v1/marketplace/install-local/ID, the package still reads read-only until a restart. The engine's manifest map keeps the id. POST /packages/BASE/ensure-customization then answered 201 created: true, writable: false. Measured on the pre-fix and fixed builds. The 409 above names the restart for this reason.
  • Read from code, not measured:
    • the pair export omits emailTemplates and translation items, so those overrides do not round-trip;
    • --install-mode unmanaged on a pair still takes the unmanaged copy path;
    • the install-time override gate's overrideWriteItemsOf does not read viewItems (for a pair the save door judges each item).
  • No packages/spec edit. The file surface is the claim's: packages/cli/src/commands/package/**, plus the install door that packages/runtime/src/domains/packages.ts hands install-local to (packages/cloud-connection/src/marketplace-install-local-*.ts).

Verification

Dev measurement, two os dev showcase runtimes. Source: customize a view and a dashboard into the pair, publish, os package export. Target: os package install the pair's objectstack.json.

Before the fix (main 07595c0)
install exit 0  ✓ Package installed into the running kernel
target read view showcase_task.in_progress {"label":"In Progress"}            (base label)
target customizations 200 {"overrides":0,...}
target ensure {"created":false,"writable":false}
target save into pair 422 WRITABLE_PACKAGE_REQUIRED
ledger: examples/app-showcase/.objectstack/installed-packages/com.example.showcase.custom.json (fresh OS_HOME)
After the fix: fresh target
install exit 0  ✓ Customization package installed into the writable pair of 'com.example.showcase'
                  Pair: com.example.showcase.custom (created by this install) · Items written: 2
target read view showcase_task.in_progress {"label":"OV-30106 label view","overrides":{"package":"com.example.showcase","basedOn":"0.1.0"}}
target read dashboard showcase_chart_gallery {"label":"OV-30106 label dashboard","overrides":{...}}
target customizations 200 {"overrides":2,...}
target ensure 200 {"created":false,"writable":true}
target save into pair 200 · read after save {"label":"OV-30106 label edited on target"}
install again: exit 0 · Pair: com.example.showcase.custom · Items written: 2
ledger files in cwd: []   (every step)
After the fix: a target holding the pair as code from an earlier install
boot with the pair ledgered → install exit 1
  409 RESOURCE_CONFLICT: ... installed here as code, by an earlier install ... Uninstall it (DELETE /api/v1/marketplace/install-local/com.example.showcase.custom), restart this runtime ..., and install it again ...
DELETE install-local 200
install exit 1
  409 RESOURCE_CONFLICT: ... has been uninstalled, but its code registration stays in this runtime until it restarts ... Restart this runtime and install it again ...
restart → install exit 0 · Items written: 2 · customizations {"overrides":2}
restart again → customizations {"overrides":2}, both override labels served
Gates at 6be0aea
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack  → 64 commands, each run, exit codes recorded before any pipe
non-zero on the first pass (at c039b0ed):
  pnpm check:durability-log-level :: exit 1
    (the save loop's catch had no log; it now logs at error with the save's own words)
  node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 3
    (shallow clone; its pinned fixture commit fetched)
final pass at 6be0aea6a: 64 :: exit 0
dispatch-gates --ran: 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED
pnpm lint :: exit 0

🤖 Generated with Claude Code


Generated by Claude Code

… writable pair

An exported `<base>.custom` installed through install-local was registered
as code: none of its overrides was served, the customization read listed
none, and the pair turned read-only, while the install answered success.

A pair is now installed as what it is (ADR-0146 D2 / D3): the target's own
pair through its ensure door, every item written through the save door with
`mode: 'publish'`, nothing registered, synced or ledgered. Refusals the body
or the target decide are answered before any write; a save refused part-way
answers failure, removing a pair the install created. A pair already
read-only on the target is refused 409 naming the way back. The CLI prints
the pair it wrote, and prints an older runtime's as-code answer as an error.

Claude-Session: https://claude.ai/code/session_01Utp6CXfNPQKEBNuyohXjfg
Co-authored-by: Claude <noreply@anthropic.com>
…, in its catch

The install the operator asked for did not land, and the pair is not what
the export describes; the durability gate reads the catch, so the line is
written there with the save's own words.

Claude-Session: https://claude.ai/code/session_01Utp6CXfNPQKEBNuyohXjfg
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/cloud-connection, touching 38 documentable anchor(s).

36 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json dfafae81324e508cc666171d3385a15b89f31050.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 38 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 dfafae81324e508cc666171d3385a15b89f31050 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1d621cbbda45dd06bc5c79f61e2992bb6a102259 — the merge of head 6be0aea6a64d61bac1d2831d33da398c967deee7 into base dfafae81324e508cc666171d3385a15b89f31050, 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 1d621cbbda45dd06bc5c79f61e2992bb6a102259 && git checkout 1d621cbbda45dd06bc5c79f61e2992bb6a102259
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dfafae81324e508cc666171d3385a15b89f31050 6be0aea6a64d61bac1d2831d33da398c967deee7 && git checkout -B drift-repro dfafae81324e508cc666171d3385a15b89f31050 && git merge --no-ff 6be0aea6a64d61bac1d2831d33da398c967deee7

node scripts/docs-audit/affected-docs.mjs --json dfafae81324e508cc666171d3385a15b89f31050

⚠️ 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 dfafae81324e508cc666171d3385a15b89f31050 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6be0aea6a64d61bac1d2831d33da398c967deee7
Local-runs: none
Scope: packages/cloud-connection/src/marketplace-install-local-plugin.ts, packages/cloud-connection/src/marketplace-install-local-customization-pair.ts, packages/cloud-connection/src/marketplace-install-local-customization-pair.test.ts, packages/cloud-connection/src/marketplace-install-local-customization-pair-install.test.ts, packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts, packages/cloud-connection/src/marketplace-install-local-extension-field-suffix.test.ts, packages/cli/src/commands/package/install.ts, packages/cli/test/package-install-customization-pair.test.ts, packages/cli/test/package-export.test.ts + packages/metadata-protocol/src/package-writability.ts, packages/metadata-protocol/src/protocol-customization-package.ts, packages/metadata-protocol/src/protocol-save.ts, packages/metadata-protocol/src/protocol-packaged-base-refusal.ts, packages/spec/src/kernel/unmanaged-install.ts, packages/spec/src/meta-spelling/manifest-collection-spelling.ts, packages/spec/src/api/package-lifecycle.zod.ts, packages/spec/src/api/protocol.zod.ts, packages/spec/src/api/errors.zod.ts, packages/runtime/src/domains/packages.ts, packages/types/src/thrown-http-error.ts, packages/qa/dogfood/test/package-export-reinstall-custom-pair.dogfood.test.ts, packages/qa/dogfood/test/package-export-bundle-round-trip.dogfood.test.ts, docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md

Face, read off the committed tree (10 files, gh api .../pulls/30207/files): no packages/spec/src/** path, no governed path (record-recognisers.mjs --paths on the file list derives no face and defers to the changeset's Clause-②: line, which reads no with no arm). The review is owed to the dev's measured open_question (a possible narrowing arm), so it is served at this tier with ④ and the narrowing-face floor: every non-prose path of the diff is in Scope. The PR author is objectstack-fleet[bot] (relay); the implementing identity is the claim's branch.

① Derived judgments

Accept-set and public-surface changes the diff makes, each judged:

  1. Pair id routed off the managed path (marketplace-install-local-plugin.ts, handleInstall after step 1d', customizationBasePackageId(manifestId) !== null ⇒ installCustomizationPair). Correct and the card's fix: ADR-0146 D3 says the pair is "an ordinary writable package", D2 that its items are stored rows a served read reads (protocol-served-overrides.ts#listServedOverrides selects package_id = pair, state: 'active', card comment 6633535319). Registering it as code (manifest.register + ledger + syncSchemas) was never the declared contract. Ordered after 1c' (dependency check, so a missing base is refused 422 before the pair branch), 1c'' (__c reservation), 1e (install mode), 1d (unrunnable code), 1d' (withheld maps): the pre-existing refusals still apply to a pair body. Judged right.

  2. Pair body keys with no item form refused 422 VALIDATION_ERROR (marketplace-install-local-customization-pair.ts#planCustomizationPairInstall: a key not in PLURAL_TO_SINGULAR, not viewItems, not objectExtensions, carrying content, whose UNMANAGED_INSTALL_KEY_DISPOSITIONS entry is code/uncopyable/envelope/converted or absent). Read against what the export writes (packages/runtime/src/domains/packages.ts#assemblePackageManifest: the PLURAL_TO_SINGULAR collections minus datasources/emailTemplates, viewItems, objectExtensions, then id/name/version/label?/namespace/dependencies/withheldMaps?): every key a pair export writes is admitted (id/namespace = base, name/version = identity, label/withheldMaps/data/installModes = door). translations is not in PLURAL_TO_SINGULAR (manifest-collection-spelling.ts, ends at books) and is converted in the table, so it is refused as the test pins; the export never writes it for a pair. Not a narrowing of a published accept set: POST /api/v1/marketplace/install-local has no request/response schema in packages/spec/src/api/** (grep: only ledger prose and package-lifecycle.zod.ts comments name the route), and the refused bodies installed nothing that worked (0 overrides served, pair read-only, measured in 6633535319). Judged right; Clause-②: no holds.

  3. Pair inside a multi-package artifact refused 422 (details.pairs). The published bundle flow (GET /packages/export, PackageExportBundleSchema) installs entries one at a time, { manifest: entry } (package-export-bundle-round-trip.dogfood.test.ts lines 202, 412), so no published consumer sends a pair inside packages[]. The refusal lists every pair, before any write. Judged right, not a narrowing of a published path.

  4. Pair manifest refused 400 PLUGIN_MANIFEST_INVALID when it lacks the explicit namespace or the base dependency (customizationPairManifestRefusal, protocol-customization-package.ts line 206). The same predicate is asked on main by installPackage (protocol-package-install.ts:249), POST /packages (runtime/src/domains/packages.ts:1719) and the duplicate door (protocol-package-commits.ts:241); install-local asking it is the declared contract pulled back, not a new requirement. Current exports carry both keys (PackageExportManifestSchema.namespace / .dependencies). Judged right.

  5. Pair already read-only refused 409 RESOURCE_CONFLICT before the ensure door (isWritablePackage(ql, manifestId) from package-writability.ts:73: engine.manifests.has first, then READ_ONLY_PACKAGE_SCOPES by manifest scope, else writable). The three remedies are derived from this.ledger.has, this.bootUserCodeIds, and manifest scope; the ensure door would otherwise answer 201 created: true, writable: false (dev measured). Judged right.

  6. Writes through saveMetaItem with mode: 'publish', packageId: pair, actor: userId, in loadOrder (SaveMetaItemRequestSchema, protocol.zod.ts:1103: publish writes the active row; protocol-save.ts#saveMetaItemUnaudited line 498/1651 state: 'active'). The save door's own judges run on every item: isWritablePackage (line 1881, WRITABLE_PACKAGE_REQUIRED), overrideWriteVerdict/refusePackagedBaseOverride (the D2 declaration and closure), objectExtensionItemRefusal (line 1197, an object_extension named by its target, as the planner names it). So the dev-flagged gap in the install-time gate (overrideWriteItemsOf walks PLURAL_TO_SINGULAR keys only, protocol-packaged-base-refusal.ts, and so not viewItems) is closed by the save door for a pair. Judged right.

  7. Answer shape: a pair answer carries customizationPair { basePackageId, packageId, created, writable }, writtenCount, written, note, optional seeded/withheldMaps, and no hotLoaded/storageDir. Public-surface reading: @objectstack/cloud-connection exports is . only, whose entry (src/index.ts) exports MarketplaceInstallLocalPlugin and MarketplaceInstallLocalPluginConfig; the route answers c.json(...) untyped, so no exported type reaches the body. @objectstack/cli exports (., ./console, ./hook-body) reach PackageInstallCommand (an oclif Command class, no response type on it). No spec response schema exists. Nothing published is widened; no new ErrorCode is minted (RESOURCE_CONFLICT, PLUGIN_MANIFEST_INVALID, SERVICE_UNAVAILABLE, NOT_OVERRIDABLE are ledger rows; VALIDATION_ERROR is errors.zod.ts:55). Judged right; Clause-②: no without an arm. A no (narrowing) arm would be BREAKING under AGENTS.md step 3 (ADR-0087 marker, migration) for shapes that never produced a working install: not warranted.

  8. CLI (packages/cli/src/commands/package/install.ts): prints the pair answer; a pair id answered without customizationPair (older runtime) prints an error naming DELETE .../install-local/{id}, upgrade, restart, and exits 1. Correct reading of the mixed-version matrix; an older CLI against a new runtime prints the managed success lines (benign: the install did land). Judged right.

  9. Metadata-protocol: none holds (AGENTS.md Post-Task Checklist step 3): no spec key, value or type added, removed or re-meant; the manifest requirement in item 4 is already the published predicate; the door refusals are install behavior, not authoring protocol.

② Semver level

.changeset/30106-install-custom-pair-into-writable-pair.md: @objectstack/cloud-connection: patch, @objectstack/cli: patch, both released (package.json has no private). Kind none ⇒ patch is the level step 3 prescribes; both line-initial declarations present and matching the PR body. No content/docs/releases/ edit, no file outside the card (packages/qa/dogfood/** untouched, the two pair dogfood files pass at head per the dev's report). Consistent.

③ Boundary flags

  • open_question, Clause-② re-check (A keep no / B yes (narrowing)): A. Grounds in ① items 2, 3, 4, 7. B's spelling is also malformed under step 3 (the arm pairs as no (narrowing)), and the arm is BREAKING, which the facts do not support: nothing a consumer could rely on is taken away.
  • Cached-manifest location not keyed by OS_HOME (LocalManifestSource default CWD/.objectstack/installed-packages): out of this card; a pair no longer writes one. Follow-up card owed (dev's out_of_scope class a); not a defect of this PR.
  • engine.manifests keeps an uninstalled id until restart: read in package-writability.ts:77; the 409 remedy names the restart; the dogfood "stale code pair" sequence was measured. Out-of-scope class a, not a defect of this PR.
  • Pair export omits emailTemplates and translation items: read in assemblePackageManifest (exportPluralKeys filter). Those overrides do not round-trip today; a card on the export side, not this door. Noted.
  • --install-mode unmanaged on a pair takes the unmanaged copy path: only when the pair manifest declares installModes including unmanaged, which the ensure door never writes and the export never adds (6633535319: INSTALL_MODE_NOT_PERMITTED otherwise). Noted, not a defect here.
  • Authorization of the pair write: install-local's route requires manage_metadata (INSTALL_LOCAL_CAPABILITY, plugin line 377, checked at 5392); the ensure door's docblock leaves the walled-posture judgment to the HTTP door. A managed install already changes environment-wide state under the same capability, so the pair path widens no permission boundary. Judged, not a defect.
  • Observation, not must-fix: a pre-fix ledgered pair whose boot manifest.register failed (the lenient hotLoaded: false path) reads writable, so the new path writes rows and leaves the stale ledger entry, which a later boot may register as code again. Pre-fix state only; the 409 remedy (DELETE, restart) clears it. Suggest a follow-up card: refuse or auto-remove a ledger entry for a pair id at step 3.
  • Observation: the answer's version echoes the export's version; the target pair's row keeps the ensure door's 0.1.0 or its existing version. Informational.
  • Check-runs at the head when read: 21 success, 13 skipped, 12 in progress (Test Core 1-8, four Lint jobs). Landing waits on all green; this record does not substitute for that read.

④ Design judgment

Against ADR-0146 D2/D3 and the card's acceptance:

  • Target serves the overrides / customizations read lists them: every item becomes a sys_metadata row of the pair in active state through the save door a Studio edit takes (① item 6); listServedOverrides reads exactly those rows. Pinned at the route (marketplace-install-local-customization-pair-install.test.ts "writes every item through the save door", asserting the exact saveMetaItem calls, register/syncSchemas never called, no ledger file) and measured e2e in the PR body (overrides: 2, both labels served across two restarts). Holds.
  • Pair stays writable: nothing enters engine.manifests, the ledger or the cached-manifest directory for the pair id; the answer carries writable: true only after the ensure door answered it; writable !== true is refused 409. Measured ensure writable: true, a further save 200. Holds.
  • Failure is not reported as success: a refused save answers the save's status and code (resolveThrownHttpError, thrown-http-error.ts:225, reads .status then .statusCode), naming the item and path; refusal tests assert status (422/409/400/503) and code (VALIDATION_ERROR, RESOURCE_CONFLICT, PLUGIN_MANIFEST_INVALID, SERVICE_UNAVAILABLE) per the ADR-0112 envelope; the CLI's non-OK path prints status and code and exits 1. Holds.
  • Rollback: a pair this install created is deleted through deletePackage({ packageId, actor }) (protocol.zod.ts:1681 failedCount); a non-zero failedCount or a thrown delete is logged at error and said in the answer; a pre-existing pair is kept with details.written/details.notWritten, every item re-saved on the next run. Partial publish into a pre-existing pair is disclosed, not hidden. Holds.
  • Nothing registered as code any more: the three pre-existing tests that pinned registered === [PAIR] now pin registered === [] with the saves named; the install path's installCustomizationPair returns before 1f/2a-2e's register, ledger write and syncSchemas. Holds.
  • Refusals before any write: planner, override gate 2d, field-patch check 2e, manifest predicate, read-only pre-check, door presence (503) all precede the ensure call; the ensure door itself writes only through installPackage and is idempotent (PAIR_FLIGHTS). Holds.
  • Narrowing against the published spec on main: the refused bodies and the bundled pair take no declared capability away at the consumer; they make D2/D3's own posture real at this door. The one-item-per-identity rule uses the save door's own key (itemDiscriminator, (name, locale) for email_template), so the planner and the row store agree.

No must-fix found.

Implemented-by: claude/project-thread-j915rv
Reviewed-by: session_01Utp6CbPxYq1wQmUNWvMZkC

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC

席位核对

ACCEPT · head 6be0aea6a64d61bac1d2831d33da398c967deee7 · card #30106 (claim 6646484259) · 2026-11-05T02:28Z

  • Shape: draft PR to main, author objectstack-fleet[bot] (relay pr_create), attribution block then Fixes #30106 on its own line; no other open card number sits next to a closing keyword. 10 files, +1325 −40 (below the 3000-line human-merge threshold). No governed path, no packages/spec/src/** path ⇒ not Tier H; the seat lands it.
  • Premise measured: the dev confirmed the seat's assumption (the old path registered the pair as code and wrote no sys_metadata row) and found the pair also turned read-only; fix and pins in the PR body.
  • Gates (check-runs on this head): 48 runs, 35 success, 13 skipped, 0 failed. Lint & Repo Gates success, TypeScript Type Check success. The 13 skips are path-gated UI/docs jobs, opt-in harnesses, push-only dogfood and the schedule gate; check-expected-skips.mjs could not run in this container (its yaml dependency is not installed), so the skip roster was read by name, not by the tool.
  • Contract review: PASS at CONTRACT_REVIEW_TIER, record 6647885067 on this head. Clause-②: no stands (no published schema or exported type reaches the install-local body or answer); Metadata-protocol: none stands; changeset patch on both released packages, consistent.
  • Prose read against the diff: the changeset's "Before", "Now", "Refusals, nothing written", "A save refused part-way" and "The CLI" bullets; each matches the code and tests.
  • Accepted deviations: the file surface grew from the claim's packages/runtime/src/domains/packages.ts to the door it hands off to, packages/cloud-connection/src/marketplace-install-local-* (the actual install door). In the same package family and in the claim's "and the install door it calls"; no packages/spec.
  • Out of scope: filed install-local: an uninstalled package still reads read-only until a restart, because engine.manifests keeps its id #30215 (an uninstalled package keeps reading read-only until a restart) and install-local: the cached-manifest directory is resolved from the working directory, not OS_HOME, so os dev --fresh rehydrates a previous install #30217 (the cached-manifest directory follows the working directory, not OS_HOME). Noted, not filed (read from code, not measured): pair export omits emailTemplates and translation items; --install-mode unmanaged on a pair; overrideWriteItemsOf not reading viewItems (covered at write time by the save door, per the review).
  • Writes: api_writes 2, mcp_calls 5, all reads.
  • Landing: pr_ready then automerge_enable 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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.custom export installed on another environment reports success and the override is not applied

1 participant