Skip to content

Commit 486b0a9

Browse files
feat(metadata-core, metadata-protocol, runtime)!: an App and its modules share one customization pair (#31134)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiP9RS1KjicEPyef7VNhFzFa)_ Fixes #29594 Clause-②: yes (narrowing) — the ensure door refuses two asks it accepted: a package that reaches several Apps and whose namespace picks none of them (was `201` with its own pair, now `409`, merge question 11), and a module whose earlier per-package pair holds the App's pair namespace (was `201`/`200`, now `409 RESOURCE_CONFLICT` naming that pair, merge question 12); `appOf` now returns `string | undefined`. The widening half: a module of an App gets the App's pair, and `@objectstack/metadata-core` exports `pairsOf`, `AppOfRefusal` and `PairOfRefusal`. Claim amendment 6707871792. Metadata-protocol: semantic Skills-docs: follow-up owed — skills/objectstack-platform/SKILL.md (the "Customizing an installed package" paragraph names one pair per base; it should say a module of an App customizes in the App's pair) ## 维护者速读 - 改了什么:一个 App 和它带来的模块现在共用一个定制包 `<App id>.custom`。HotCRM 的销售 App 和 3 个模块,无论从哪个包点「定制」,都落进同一个 `app.objectstack.hotcrm.custom`。 - 为什么改:按裁决 6691022506,定制单位是 App。以前每个模块各要一个定制包,命名空间相撞时直接 422,HotCRM 的模块根本定制不了。 - 风险与回滚:有两种少见情况从成功变成 409(同时挂两个 App 且命名空间选不出的包;模块的旧定制包占着 App 的命名空间),PR 和 changeset 里写了改法。旧版本给模块建的按包定制包继续被读取和服务,不迁移、不删除;它占着 App 的命名空间时,ensure 门回 409 说明原因,不再回 422。回滚就是还原本 PR,已建的 App 定制包仍是普通可写包。 - 先看:`packages/metadata-core/src/customization-pair-lookup.ts`(appOf / pairOf / pairsOf),然后 `packages/metadata-protocol/src/protocol-customization-package.ts`(ensure 门)。 - 两分钟验证:`cd packages/runtime && npx vitest run --project local src/domains/packages-ensure-customization.test.ts src/domains/meta-object-extension-item.test.ts`,看「App unit」两组用例。 - 需要你做的:无,席位落地 ## BREAKING Accept-set narrowing at `POST /api/v1/packages/:id/ensure-customization` (`@objectstack/metadata-protocol`) and a return-type change in `@objectstack/metadata-core`. ADR-0087 disposition (in the changeset): `not-required (no-migration-prescription)`, since no schema key moves and nothing at rest changes. - **A package that reaches several Apps and whose namespace picks none.** Before: `201` with its own pair `<package id>.custom`. Now: `409 RESOURCE_CONFLICT`, nothing created. Remedy: declare the one App the package belongs to in `manifest.dependencies`, or give it that App's namespace. - **A module whose earlier per-package pair holds the App's pair namespace.** Before: the module's own `<module id>.custom` (`201`, then `200`). Now: `409 RESOURCE_CONFLICT` naming that pair, from the App and every member; the pair stays installed and served. Remedy: keep customizing in that pair, or export it (`GET /api/v1/packages/<pair>/export`), delete it, and ensure again to get the App's pair. - **`appOf` returns `string | undefined`** (was `string`); `undefined` = no single App. Remedy: `appOf(id, installed) ?? id` keeps the old reading; `pairOf`'s refusal reason may now be `'no_single_app'` (`PairOfRefusal`). ## What changes The customization unit is the App, as ruled on #29594 (ruling record 6691022506): > **The customization unit is the App** (ADR-0019 D1–D4: the `type: app` package and the `type: module` / `plugin` packages its artifact brings; ADR-0130 D1/D6: one artifact, one version). One pair per App: `<app id>.custom` (HotCRM → `app.objectstack.hotcrm.custom`), namespace = the App package's own namespace + `_custom`, dependency = the App's `^major`. A module never owns a pair. Customize on any object of any member lands in the App's pair; the ensure door returns the App's existing pair (200) instead of 422. 422 `NAMESPACE_CONFLICT` stays for a namespace held by a package outside the App's artifact (registry `coOwnersOf()` arms unchanged). Kernel objects stay not customizable in this version (ruling record 6691217245). ADR-0146 D3 as amended by #31027 governs; merge questions 10, 11 and 12 are implemented as written. - **`appOf` reads the installed manifests** (`packages/metadata-core/src/customization-pair-lookup.ts`). A `type: 'app'` package is its own App. Any other package's App is the installed non-kernel `type: 'app'` package it declares in `manifest.dependencies`; with none declared directly, the walk follows declared parents and stops at the first App on each path (cycle-safe, kernel and uninstalled ids skipped). Several Apps: the one holding the package's namespace; none of them, or several: `undefined`, and `pairOf` refuses with `no_single_app` (merge question 11). A package that declares no App is its own unit (N2 holds). A kernel package (scope `system` / `cloud`) keeps its own answer, because no platform pair is created in this version; a new metadata-protocol pin holds that list equal to `READ_ONLY_PACKAGE_SCOPES`. - **Two questions, two readers.** `pairOf(base)` answers where a customization of `base` lands (the App's pair). `pairsOf(base)` / `isPairOf(pair, base)` answer which packages' rows are `base`'s customizations: the App's pair, then the per-package pair an earlier version created for `base` when it is installed (merge question 12). Every read site PR 2 threaded now asks `pairsOf` / `isPairOf`: list reads, layered reads, overlay merge, served-overrides census, translation bundle binding, environment rows. - **The ensure door** (`protocol-customization-package.ts`) answers the App's pair for any member: id, namespace `<app ns>_custom`, dependency `{ [app]: ^<app major> }`, name `<App name> (custom)`, and `basePackageId` = the App. `201` the first time any member asks, `200` after. Refusals: a kernel base `409` (unchanged); `no_single_app` `409`; the App namespace held by a legacy pair of a member of the same App `409 RESOURCE_CONFLICT` naming it (it stays installed and served; never created beside it); held by any package outside the App `422 NAMESPACE_CONFLICT` (unchanged). - **Write closure.** `widenPairClosureToAppMembers` (`protocol-write-package-scope.ts`) lets a pair whose closure includes its App reach every installed package whose `pairOf` is that pair, so an override or an `object_extension` of a module's item saved into the App's pair is admitted. The packaged-base refusal's pending-pair scope uses the same widening. - **Legacy pairs honoured by every reader** (merge question 12, contract review should-fix 1): `isPairRowRemoval` asks `isPairOf`, so a legacy `<module>.custom` still removes its own declared override past the sealed-removal refusal; the bundle-member reader (`pairsOfAddedMember` / `findPairAddedMemberRow`, `protocol-write-gates.ts`) reads every pair in `pairsOf` order; the customizations read (`packages.ts`) lists object extensions from every pair and never counts a pair as another package's contribution; the sealed and undeclared-same-name sentences name the legacy pair as a pair. No reader is App-pair-only. - **Census** (`protocol-packageless-overlay-migration.ts`): the incomplete-declaration census reads the base from the row's declared `overrides.package` checked against `isServedOverrideRowOf`, instead of deriving it from the pair id, so a module's override in the App's pair is listed under the module. - **Uninstall** (`registry.ts`): the refusal for an installed pair reads the pair named for the package among `pairsOf`. Uninstalling the App is refused while the App's pair is installed; uninstalling a module is not; a module whose own earlier pair is installed is still refused naming it. - **Lint** (`validate-customization-namespace.ts`): `namespace-no-customization-pair` skips a delivered package whose `appOf` is another delivered package. `@objectstack/lint` gains a `workspace:*` dependency on `@objectstack/metadata-core`. - **Dogfood helper** (`packages/qa/dogfood/test/customization-pair.ts`): `ensurePair` reads the pair id from the door's answer and asserts it. - Docs: `content/docs/concepts/metadata-lifecycle.mdx`, `content/docs/deployment/install-modes.mdx`, `content/docs/api/metadata-api.mdx`. - `packages/objectql/src/plugin.ts` is not edited by this PR (draft PR #31093 edits it; no overlap). ### N1 rule (decided here) A per-package pair an earlier version created for a module (namespace e.g. `crm_svc_custom`) is **not moved** by this PR: it stays its own installed package and is served beside the App's pair through `pairsOf`. When a move ceremony is built, the objects it carries keep their `crm_svc_custom_<short>` names as a **declared exception**, and the App's pair is not given a set of namespaces. Why: no live instance exists to move; renaming a data-holding table is refused by the platform; and a namespace set would permanently widen what the App's pair owns, where a declared exception stays visible and bounded. ## Tests - `packages/metadata-core/src/customization-pair-lookup.test.ts`: 13 resolution rows (App, HotCRM module, module with a kernel dependency, nested module, plugin with two Apps where one holds the namespace, two Apps where neither does, standalone App, own-unit plugin, cycle, uninstalled App dependency, kernel, kernel declaring an App, no namespace), nothing-installed equals the old derivation, HotCRM's four packages answer one pair, legacy arm of `pairsOf` / `isPairOf`. - `packages/runtime/src/domains/packages-ensure-customization.test.ts`, "the App unit": module first then App and others `[201, 200, 200, 200]`, one pair, one publish; outside-App namespace `422`; legacy member pair `409` from the App and every module, legacy unchanged; two-App plugin `409`; own-unit plugin its own pair `201`. - `packages/runtime/src/domains/meta-object-extension-item.test.ts`: the HotCRM-shape rehearsal (below). - `packages/runtime/src/domains/meta-legacy-pair-served.test.ts` (contract review should-fix 2; on both topologies, a host-config kernel and an environment kernel, since the incremental review): HotCRM-shape App + module shipping a dashboard and a doc, and the module's legacy pair `app.objectstack.hotcrm.service.custom` holding a declared override written straight to the store (a row an earlier version wrote). Through `HttpDispatcher`'s catch-all, the override is served at the module's address by name, in the list and in the layered read; through `RestServer`'s `DELETE /meta/:type/:name?package=<legacy pair>` the pair removes it (the doc, which has no overlay channel, included), and the module's item is served again. A third case: the legacy pair still saves a declared override. - `packages/objectql/src/registry-assert-package-uninstallable.test.ts`: the App refused naming the App's pair, a module not; a module with its legacy pair refused naming it. - `packages/objectql/src/protocol-packageless-overlay-migration.test.ts`: a module's override in the App's pair is listed under the module. - `packages/metadata-protocol/src/package-writability-kernel-scopes.test.ts`: `appOf`'s kernel scopes equal `READ_ONLY_PACKAGE_SCOPES`. ### Rehearsal HotCRM (`dev:crm`) is not reachable from this checkout, so the rehearsal is an in-repo fixture of the same shape, run through the real dispatcher and the real lookup (nothing stubbed): App `app.objectstack.hotcrm` (namespace `crm`) and three modules `…service` / `…revenue` / `…marketing` (type `module`, namespace `crm`, each declaring the App `^6`), registered with `engine.registerApp` on a SqlDriver `:memory:` kernel. Ensure via `HttpDispatcher.handlePackages` answers `[[app, 201, app.objectstack.hotcrm.custom], [service, 200, same], [revenue, 200, same], [marketing, 200, same]]` with one pair installed. An `object_extension` adding `sla_tier__c` to the module object `crm_service_case` is saved and published into the App's pair; `getMetaItem`, `getMetaItemLayered` and the registry read it back, its extenders are `[app.objectstack.hotcrm.custom]`, and a record inserted with `sla_tier__c: 'gold'` stores it. ## Acceptance notes - Negative pins were ablated (refused behaviour put back, build, dist preflight, run red, restore with blob == HEAD): - 422 kept for an outside-App namespace: the same-App guard forced true → 3 red (`the namespace held by a package outside the App: 422 NAMESPACE_CONFLICT still, asked from a module` and the two older 422 pins); restored → green. - Kernel 409: the kernel-scope check disabled → 5 red (kernel `system` / `cloud` ensure, kernel legacy pair, flow clone for both scopes); restored → green. - App resolution: `appOf`'s single-App answer replaced by the package itself → 3 red (module-first ensure, legacy-pair 409, rehearsal); restored → green. - Known limits, left as follow-ups: the pair-added bundle member reader (`protocol-write-gates.ts`) reads only the App's pair, not a legacy pair; no ceremony re-identifies a legacy pair as the App's (merge question 12 leaves it open); the six `ui/packages/app-shell` call sites that spell `customizationPackageId(base)` (domain:ui); the list-row `customizable` flag needs a spec sub-card. ## Verification <details><summary>Scoped tests</summary> ``` metadata-core Test Files 25 passed · Tests 989 passed metadata-protocol Test Files 320 passed | 4 skipped · Tests 22524 passed | 26 skipped (1 red fixed, file re-run 91/91); new kernel-scopes pin 2/2 objectql Test Files 564 passed · Tests 11422 passed; registry files 49 passed / 698 after the uninstall fix; uninstall file 11/11 runtime Test Files 554 passed · Tests 8914 passed | 19 skipped; App-unit files 3 passed / 87 lint Test Files 197 passed · Tests 8331 passed (re-run after the alias: 8330 + runtime-lazy-deps raced a concurrent rebuild of lint/dist, re-run alone 6/6) cli (unit) Test Files 396 passed · Tests 5576 passed cloud-connection Tests 935 passed (full re-run after the uninstall fix) service-automation Test Files 264 passed · Tests 3431 passed service-datasource Test Files 72 passed · Tests 1279 passed typecheck metadata-core, metadata-protocol, objectql, lint, runtime, qa/dogfood :: exit 0 eslint (changed) :: exit 0 ``` </details> <details><summary>Ablations</summary> ``` # 1 · 422 kept for a namespace held outside the App (metadata-protocol/src/protocol-customization-package.ts) anchor "if (ownerBase !== null && customizationPackageId(ownerBase).ok && appOf(ownerBase, installed) === appId) {" x1 -> x0 blob 8f868ff -> 1ad2eed1b3de · dist preflight: marker present in 2 built files × the composed namespace is owned by another package: 422 NAMESPACE_CONFLICT naming the owner × an owner the registry would read as a co-owner is refused all the same: the pair namespace is the pair's alone × the namespace held by a package outside the App: 422 NAMESPACE_CONFLICT still, asked from a module Tests 3 failed | 40 passed (43) restored: blob == HEAD (8f868ff), git diff HEAD empty; rebuilt, dist preflight --absent ✓ restored run (3 files): Test Files 3 passed · Tests 87 passed # 2 · kernel 409 (same file) anchor " if (kernelScope !== undefined) {" x1 -> x0 blob 8f868ff -> 2f0051e03486 · dist preflight: marker present in 2 built files × a kernel base (scope 'system'): 409, not customizable in this version, nothing created × a kernel base (scope 'cloud'): 409, not customizable in this version, nothing created × a kernel pair an earlier version created stays installed: only the ensure call is refused × scope 'system', no `package` named: 409 with the ensure door's sentence, and the engine holds no copy × scope 'cloud', no `package` named: 409 with the ensure door's sentence, and the engine holds no copy Tests 5 failed | 59 passed (64) restored: blob == HEAD (8f868ff), git diff HEAD empty; restored run green (as above) # 3 · App resolution (metadata-core/src/customization-pair-lookup.ts) anchor " if (apps.length === 1) return apps[0];" x1 -> x0 blob 16a7bd5 -> ee33f4027082 · dist preflight: marker present in 2 built files × a module first, then the App and the other modules: one app.objectstack.hotcrm.custom, 201 then 200 × a per-package pair an earlier version created for a module holds the namespace: 409 naming it, never 422; it stays installed (merge question 12) × ensure on the App and each module answers one app.objectstack.hotcrm.custom; a module object extended there reads back Tests 3 failed | 63 passed (66) restored: blob == HEAD (16a7bd5), git diff HEAD empty; rebuilt, dist preflight --absent ✓ ``` </details> <details><summary>Dispatch gates</summary> Derived at `5ac6ed0f` (111 commands); the later merge of `origin/main` (`016eb459`) touches none of this diff's files and derives the same list. ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-console-intercept-disarm.mjs :: exit 0 node scripts/check-console-intercept-disarm.mjs --self-test :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-engine-split-ratio.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-osv-exemptions.mjs :: exit 0 node scripts/check-osv-exemptions.mjs --self-test :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/ci/snapshot-next.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/osv-base-relative.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:llms-txt :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:changeset-fixed :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:filter-alias-parity :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:manifest-repository-directory :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:overlay-whitelist-table :: exit 0 pnpm check:override-consistency :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pnpm-flag-spellings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:route-envelope :: exit 0 pnpm check:service-providers :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:turbo-task-graph :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-export-contract-resolve :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:vouch-for-reference-callers :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm check:workspace-manifest-cycles :: exit 0 dispatch-gates --ran: 111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN :: exit 0 pnpm lint :: exit 0 node scripts/check-platform-checklist.mjs --changed-from origin/main :: exit 0 ``` First pass: 3 gates exited 3 (missing builds / the pinned teardown fixture commit) and `check:test-source-alias` exited 1 (lint needed an anchored `@objectstack/metadata-core` alias; the rehearsal test needed a module-top `import '@objectstack/metadata'`). Both fixed in `5ac6ed0f`; the prerequisites were built and the four re-run with every changeset, alias and test-input gate, all exit 0. </details> <details><summary>Narrowing follow-up at the final head <code>775c97b5</code></summary> ``` changeset commit 775c97b (Clause-② yes (narrowing), BREAKING banner, ADR-0087 not-required) node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 (ADR-0087 exemption no-migration-prescription noticed) node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 pnpm check:changeset-fixed :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 dogfood battery (os-verify-lock, build closure --filter=@objectstack/dogfood... then pnpm --filter @objectstack/dogfood test, no shard, no path) Test Files 435 passed | 3 skipped (438) Tests 3609 passed | 41 skipped (3650) os-verify-lock: VERDICT command-exit 0 · held the lock 1971s (32m51s) ``` </details> <details><summary>Contract-review patch round at the final head <code>fb2fd21e</code></summary> ``` should-fix 1 + 2: commits c0172dd..787f842, then origin/main merged (fb2fd21) ablation · legacy pair's reads (metadata-core pairsOf legacy arm disabled) blob 16a7bd5 -> 44efe1c0117c · dist preflight: marker present in 2 built files × the pair still saves a declared override of the module's item (the write gate admits it) × its declared override is served at the module's address (by name, list, layered) … byName/list/layered answered "Service board (shipped)" × a doc (no overlay channel) the legacy pair overrides is served … by-name answered the shipped doc Tests 3 failed (3) restored: blob == HEAD, git diff HEAD empty; rebuilt, dist --absent ✓ -> Tests 3 passed (3) ablation · legacy pair's removal (isPairRowRemoval back to the single pairOf) blob cca50ea -> dad267a4800c · dist preflight: marker present in 2 built files × a doc (no overlay channel) … its removal passes the sealed-removal refusal as the pair's own 403 NOT_OVERRIDABLE "… sealed against removal: its type takes no environment overlay …" Tests 1 failed | 2 passed (3) restored: blob == HEAD, git diff HEAD empty; rebuilt, dist --absent ✓ -> Tests 3 passed (3) typecheck metadata-core, metadata-protocol, objectql, runtime, lint :: exit 0 metadata-protocol Test Files 322 passed | 4 skipped · Tests 22527 passed | 26 skipped objectql Test Files 564 passed · Tests 11425 passed runtime Test Files 555 passed · Tests 8923 passed | 19 skipped pnpm lint :: exit 0 dispatch-gates --commands: 111 derived at fb2fd21, all 111 :: exit 0 dispatch-gates --ran: 111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN :: exit 0 dogfood battery at fb2fd21 (os-verify-lock, build closure then pnpm --filter @objectstack/dogfood test) Test Files 435 passed | 3 skipped (438) Tests 3609 passed | 41 skipped (3650) os-verify-lock: VERDICT command-exit 0 · held the lock 1684s ``` </details> <details><summary>Incremental-review round at the final head <code>481d4049</code></summary> ``` A. changeset: '@objectstack/runtime': minor added (packages.ts changes GET /packages/:id/customizations); title scope names runtime B. meta-legacy-pair-served.test.ts runs on both topologies (environmentId undefined and 'env_hotcrm'): Tests 6 passed (6) nit: packages.ts pair loop no longer an if…for on one line ablation · the protocol door's pair-row pass (protocol-diff-delete-load.ts:498, `!pairRowRemoval` removed) blob 9df68e0 -> bee509989cbd · dist preflight: marker present in 2 built files × a doc (no overlay channel) … its removal passes the sealed-removal refusal as the pair's own (environment kernel) "Metadata item 'doc/crm_service_guide' is provided by a managed package and is sealed against removal …" (refusePackagedBaseRemoval's own sentence) Tests 1 failed | 5 passed (6) restored: blob == HEAD, git diff HEAD empty; rebuilt, dist --absent ✓ -> Tests 6 passed (6) origin/main merged at 481d404 runtime typecheck :: exit 0 · App-unit + legacy-pair files 3 passed / 72 · src/domains/packages* 58 files / 1008 passed dispatch-gates --commands: 111 derived at 481d404, all 111 :: exit 0; --ran: 111 run, 0 NOT-MEASURED, 0 UNRUN :: exit 0 dogfood battery at 481d404: Test Files 435 passed | 3 skipped (438) · Tests 3609 passed | 41 skipped (3650) · VERDICT command-exit 0 (2423s) ``` </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- signed: domain:server#2 · marchtian · session_017ihL2dM9PvPPB9BMVdhf2R --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 66b17a3 commit 486b0a9

32 files changed

Lines changed: 1131 additions & 218 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/metadata-core': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/objectql': minor
5+
'@objectstack/lint': minor
6+
'@objectstack/runtime': minor
7+
---
8+
9+
feat(metadata-core, metadata-protocol, objectql, lint, runtime)!: an App and the modules that declare it share one customization pair
10+
11+
Clause-②: yes (narrowing) — the ensure door refuses two asks it accepted: a package that reaches several Apps and whose namespace picks none of them (was `201` with its own pair, now `409`, merge question 11), and a module whose earlier per-package pair holds the App's pair namespace (was `201`/`200`, now `409 RESOURCE_CONFLICT` naming that pair, merge question 12); `appOf` now returns `string | undefined`. The widening half: a module of an App gets the App's pair (`200`, where it was `422 NAMESPACE_CONFLICT` when the App held the namespace), and `@objectstack/metadata-core` exports `pairsOf`, `AppOfRefusal` and `PairOfRefusal`. Ruling 6691022506; ADR-0146 D3 as amended by #31027.
12+
Metadata-protocol: semantic
13+
14+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and nothing at rest changes: a pair the ensure door created stays installed and served, and the two new refusals answer an ask without writing anything, so there is nothing for a migration to rewrite. -->
15+
16+
The customization unit is the App (ADR-0146 D3 as amended, ruling record 6691022506). `appOf` in `@objectstack/metadata-core` now reads the installed manifests: a `type: 'app'` package is its own App; any other package's App is the installed `type: 'app'` package it declares in `manifest.dependencies`, directly or through its declared parents (where it reaches several, the one holding its namespace); a package that declares no App is its own unit; a kernel package (scope `system` / `cloud`) keeps its own answer, because no platform pair is created in this version.
17+
18+
What changes for a caller:
19+
20+
- `POST /api/v1/packages/:id/ensure-customization` asked for a module of an App answers the App's one pair, `<app id>.custom` (namespace `<app namespace>_custom`, a dependency on the App at `^<major>`): `201` on the first ask from any member, `200` after, never `422` for a package of the same App. The answer's `basePackageId` is the App. HotCRM: the App and its modules all answer `app.objectstack.hotcrm.custom`.
21+
- `422 NAMESPACE_CONFLICT` stays for the pair namespace held by a package outside the App.
22+
- A per-package pair an earlier version created for a module (`<module id>.custom`) stays installed and honoured as before: its declared overrides are served at the module's address (by name, in the list and in the layered read), its list rows, translations, bundle members it added, object extensions and the served-overrides census are read beside the App's pair (`pairsOf` / `isPairOf`), it may still declare and remove its own overrides of the module's items ("Restore package original" included, for a type with no overlay channel too), and the customizations read (`GET /api/v1/packages/:id/customizations`) does not list it as another package's contribution. No reader is App-pair-only. While it holds the App's pair namespace, the ensure door answers `409 RESOURCE_CONFLICT` naming it instead of creating the App's pair beside it (merge question 12).
23+
- A package that reaches several Apps and whose namespace picks none of them has no pair: the ensure door answers `409` (merge question 11).
24+
- The App's pair's write closure reaches every member of the App, so an override or an object extension of a module's item saved into the App's pair is admitted.
25+
- The uninstall refusal for an installed pair reads the pair named for the package: uninstalling the App is refused while the App's pair is installed, uninstalling a module is not (its customizations are the App pair's), and a module whose own earlier per-package pair is installed is still refused naming that pair.
26+
- `os validate`'s `namespace-no-customization-pair` warning skips a member of a delivered App: its customizations land in the App's pair.
27+
28+
TypeScript: `appOf` now answers `string | undefined` (`undefined` = no single App), and `pairOf`'s refusal union gains `no_single_app` (`PairOfRefusal`); `pairsOf` and the `AppOfRefusal` / `PairOfRefusal` types are new exports of `@objectstack/metadata-core`.
29+
30+
**BREAKING** accept-set narrowing at `POST /api/v1/packages/:id/ensure-customization` (`@objectstack/metadata-protocol`) and a return-type change in `@objectstack/metadata-core`.
31+
32+
- **A package that reaches several Apps and whose namespace picks none.** Before: `201` with its own pair `<package id>.custom`. Now: `409 RESOURCE_CONFLICT` saying the package declares several Apps and its namespace picks no single one of them, and nothing is created. Remedy: declare the one App the package belongs to in `manifest.dependencies`, or give it the namespace of the App it belongs to.
33+
- **A module whose earlier per-package pair holds the App's pair namespace.** Before: the module's own `<module id>.custom` (`201`, then `200`). Now: `409 RESOURCE_CONFLICT` naming that pair, from the App and from every member; the pair stays installed and its customizations stay served. Remedy: keep customizing in the existing pair (its id is in the refusal), or export it (`GET /api/v1/packages/<pair>/export`), delete it, and ensure again to get the App's pair.
34+
- **`appOf` returns `string | undefined`** (was `string`). `undefined` means no single App. Remedy: handle `undefined` (`appOf(id, installed) ?? id` keeps the old reading); `pairOf`'s refusal reason may now also be `'no_single_app'` (`PairOfRefusal`).

‎content/docs/api/metadata-api.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ capability changes only with a new version of the package that declares it. An
111111
sentence names that type's own route. What the reads then serve is in
112112
[Metadata lifecycle](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146).
113113
`POST /packages/hotcrm/ensure-customization` creates the pair when absent (Studio calls
114-
it on the first change); a kernel package (scope `system` or `cloud`) has no pair in this
114+
it on the first change); asked for a module that declares an App, it answers the App's one
115+
pair (`<app id>.custom`, `200` once it exists); a kernel package (scope `system` or `cloud`) has no pair in this
115116
version and answers `409 RESOURCE_CONFLICT`, though a pair an earlier version created keeps
116117
serving what it holds. `GET /packages/hotcrm/customizations` lists its overrides,
117118
extensions and switched-off items in one read, beside `contributions`: the fields (kind `field`)

‎content/docs/concepts/metadata-lifecycle.mdx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,14 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m
115115

116116
A customer's change to an installed base package — HotCRM, id `hotcrm` — is an item of
117117
the base's paired writable package **`hotcrm.custom`**, which depends on the base; the
118-
base's own items stay read-only. An item of the pair that bears a base item's name — for an
118+
base's own items stay read-only. **The unit is the App**: an App and every module or plugin
119+
that declares it (HotCRM's `app.objectstack.hotcrm` and its modules) share **one** pair,
120+
`<app id>.custom` (`app.objectstack.hotcrm.custom`, namespace `<app namespace>_custom`, depending on
121+
the App at `^<major>`), and the ensure door asked for any of them answers that one pair. A package that
122+
declares no App is its own unit, and a kernel package (scope `system` or `cloud`) has no pair in this
123+
version. A per-package pair an earlier version created for a module stays installed and keeps serving
124+
what it holds; while it holds the App's pair namespace, the ensure door answers `409` naming it rather
125+
than create the App's pair beside it. An item of the pair that bears a base item's name — for an
119126
`email_template`, one item per `(name, locale)`, the name **and** a locale the base ships — and
120127
declares in its body `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` (the base's
121128
`manifest.id` and `manifest.version`; `basedOn` is recorded and never judged; schema:

‎content/docs/deployment/install-modes.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,9 @@ the package's paired `<package id>.custom` package
112112
([how](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146)).
113113
`POST /api/v1/packages/<package id>/ensure-customization` creates the pair, or
114114
answers it when it already exists, and a save naming it
115-
(`?package=<package id>.custom`) lands there:
115+
(`?package=<package id>.custom`) lands there. A module that declares its App is
116+
customized in the App's pair, `<app id>.custom`: the same door, asked for the
117+
module, answers that pair (its `packageId`), and a save names it:
116118

117119
- an item the package ships, of a type that takes an override (a `view`,
118120
`dashboard`, `flow` and the other types ADR-0146 lists), is replaced by

‎packages/lint/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
},
5252
"dependencies": {
5353
"@objectstack/formula": "workspace:*",
54+
"@objectstack/metadata-core": "workspace:*",
5455
"@objectstack/sdui-parser": "workspace:*",
5556
"@objectstack/spec": "workspace:*",
5657
"ajv": "^8.20.0",

‎packages/lint/src/validate-customization-namespace.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
* customization namespace — so its installers can never customize it — is
66
* told so while it is still being authored.
77
*
8-
* Every installed base gets one paired writable package, `<base>.custom`, the
9-
* first time an installer customizes it; its namespace is the base's followed
8+
* Every installed App gets one paired writable package, `<app id>.custom`,
9+
* the first time an installer customizes any of its packages (ADR-0146 D3 as
10+
* amended, ruling record 6691022506); its namespace is the App's followed
1011
* by a suffix, and a base namespace too long for that suffix gets NO pair
1112
* namespace (the #28404 ruling, point 1: a named refusal, no truncation and no
1213
* hashing). The ensure door then refuses to create the pair, so the author of
@@ -36,6 +37,10 @@
3637
* - A package that is itself a pair (`isCustomizationPackage`, the one
3738
* spelling of "some base derives this id"): a pair is never paired again, and its
3839
* namespace is the pair namespace, longer than a base's by construction.
40+
* - A member of a delivered App (`appOf`, `@objectstack/metadata-core`, read
41+
* over the delivered manifests): its customizations land in the App's pair,
42+
* whose namespace is the App's, so its own namespace has no pair to fit —
43+
* the App's row is judged instead.
3944
* - The helper's other two refusals. A missing namespace is not this rule's
4045
* question, and a namespace the manifest grammar refuses for another reason
4146
* (an uppercase letter, a leading digit) is `os validate`'s refusal, with
@@ -55,6 +60,7 @@ import {
5560
customizationNamespace,
5661
isCustomizationPackage,
5762
} from '@objectstack/spec/kernel';
63+
import { appOf, installedManifestsOf } from '@objectstack/metadata-core';
5864

5965
import type { AnyRec } from './page-walk.js';
6066

@@ -104,8 +110,16 @@ export function validateCustomizationNamespace(stack: AnyRec): CustomizationName
104110
const findings: CustomizationNamespaceFinding[] = [];
105111
if (!isRec(stack)) return findings;
106112

107-
for (const { manifest, at } of deliveredManifests(stack)) {
113+
const delivered = deliveredManifests(stack);
114+
const installed = installedManifestsOf({
115+
getPackage: (id: string) => {
116+
const found = delivered.find(({ manifest }) => manifest.id === id);
117+
return found ? { manifest: found.manifest } : undefined;
118+
},
119+
});
120+
for (const { manifest, at } of delivered) {
108121
if (isCustomizationPackage(manifest.id)) continue;
122+
if (typeof manifest.id === 'string' && manifest.id !== '' && appOf(manifest.id, installed) !== manifest.id) continue;
109123

110124
const pairNamespace = customizationNamespace(manifest.namespace);
111125
if (pairNamespace.ok || pairNamespace.reason !== 'base_namespace_too_long') continue;

‎packages/lint/vitest.config.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
// This config exists for exactly two settings; everything else stays on
3+
// This config exists for exactly three settings; everything else stays on
44
// vitest's defaults, deliberately — a key added here re-specifies behaviour
55
// for every test file in the package (packages/cli/vitest.config.ts's header
66
// records the incident that taught that). Both settings are package-wide on
77
// purpose: each removes a mechanism every file here is exposed to, rather
88
// than tuning one file.
99
import { defineConfig } from 'vitest/config';
10+
import path from 'path';
1011

1112
export default defineConfig({
13+
// `@objectstack/metadata-core` is read from source, never its dist/, so a
14+
// unit test here is a verdict about the checkout
15+
// (scripts/check-test-source-alias.mjs). It is the one workspace package
16+
// lint's source imports that is not spec: `appOf`, which the customization
17+
// namespace advisory asks. Anchored, so it cannot swallow a subpath.
18+
resolve: {
19+
alias: [
20+
{ find: /^@objectstack\/metadata-core$/, replacement: path.resolve(__dirname, '../metadata-core/src/index.ts') },
21+
],
22+
},
1223
test: {
1324
// A late console.* must not redden a green suite (#10374): vitest's worker
1425
// forwards console output over RPC and discards the promise, and a write

0 commit comments

Comments
 (0)