Repository navigation
feat(metadata-core, metadata-protocol, runtime)!: an App and its modules share one customization pair - #31134
Conversation
A member package's pair is now its App's pair, read from the installed manifests (ADR-0146 D3, merge question 11). pairsOf/isPairOf keep a per-package pair an earlier version created served while it is installed (merge question 12). Kernel packages keep their own answer. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
… pairs Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
…pe rehearsal Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
…the App unit; changeset Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
…f's kernel scopes Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
…its modules Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
…tadata load at collection Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
📓 Docs Drift CheckThis PR changes 5 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 45 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3f118220d9dd50e3566f0e8076cdec10129953ad && git checkout 3f118220d9dd50e3566f0e8076cdec10129953ad
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a5cb4f1bf03293ebbe8033ba4d813bf940575d53 481d40492ddfc7af48d6362f844571efc6f44d32 && git checkout -B drift-repro a5cb4f1bf03293ebbe8033ba4d813bf940575d53 && git merge --no-ff 481d40492ddfc7af48d6362f844571efc6f44d32
node scripts/docs-audit/affected-docs.mjs --json a5cb4f1bf03293ebbe8033ba4d813bf940575d53
|
…remedies, and the ADR-0087 disposition Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
|
domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx Contract-review-claim: session_017ihL2fBJ6xsLWJsnNJGMMx · head 775c97b · 2026-11-07T14:37Z CONTRACT_REVIEW_TIER review of the whole non-prose diff, owed by the |
|
domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx Contract review — CONTRACT_REVIEW_TIER · 2026-11-07T14:48ZHead-sha: Scope: the whole non-prose diff (owed by Verdict: PASS on conformance, with two should-fixes the seat requires before ACCEPT (patch round on this PR). Conforms (checked):
Should-fix 1 — undeclared narrowing of a legacy pair (contradicts Q12 "honoured as today"):
Should-fix 2 — pin Q12 end to end: one dispatcher-level test that a legacy Nits (optional): a legacy pair of a module the newer App version no longer delivers resolves to itself and answers 422, not Q12's 409; the lint rule ( Also on record: |
… members and extensions stay read; pin merge question 12 end to end Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
…ough the doors Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
…earlier version stored Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
|
domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx Contract review — delta round 2Head-sha: Delta review · 2026-11-07T16:32Z Scope: the patch commits Verdict: both round-1 should-fixes are resolved; two new should-fixes before ACCEPT. Resolved (checked):
Should-fix A — changeset: Should-fix B — test topology: Nits (optional): nothing pins the legacy branch of After the patch, the seat does an in-seat delta review (the changeset and test topology implement no contract text). Then ACCEPT and landing: Size gate, then human merge. |
…t names runtime Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
|
domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx Contract review — ACCEPTHead-sha: ACCEPT · in-seat delta review · 2026-11-07T17:20Z Delta since round 2 (
All three review rounds are closed: the full Fable review at |
Requested by 田騏 · project thread
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
201with its own pair, now409, merge question 11), and a module whose earlier per-package pair holds the App's pair namespace (was201/200, now409 RESOURCE_CONFLICTnaming that pair, merge question 12);appOfnow returnsstring | undefined. The widening half: a module of an App gets the App's pair, and@objectstack/metadata-coreexportspairsOf,AppOfRefusalandPairOfRefusal. 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 id>.custom。HotCRM 的销售 App 和 3 个模块,无论从哪个包点「定制」,都落进同一个app.objectstack.hotcrm.custom。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.201with its own pair<package id>.custom. Now:409 RESOURCE_CONFLICT, nothing created. Remedy: declare the one App the package belongs to inmanifest.dependencies, or give it that App's namespace.<module id>.custom(201, then200). Now:409 RESOURCE_CONFLICTnaming 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.appOfreturnsstring | undefined(wasstring);undefined= no single App. Remedy:appOf(id, installed) ?? idkeeps 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):
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.
appOfreads the installed manifests (packages/metadata-core/src/customization-pair-lookup.ts). Atype: 'app'package is its own App. Any other package's App is the installed non-kerneltype: 'app'package it declares inmanifest.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, andpairOfrefuses withno_single_app(merge question 11). A package that declares no App is its own unit (N2 holds). A kernel package (scopesystem/cloud) keeps its own answer, because no platform pair is created in this version; a new metadata-protocol pin holds that list equal toREAD_ONLY_PACKAGE_SCOPES.pairOf(base)answers where a customization ofbaselands (the App's pair).pairsOf(base)/isPairOf(pair, base)answer which packages' rows arebase's customizations: the App's pair, then the per-package pair an earlier version created forbasewhen it is installed (merge question 12). Every read site PR 2 threaded now askspairsOf/isPairOf: list reads, layered reads, overlay merge, served-overrides census, translation bundle binding, environment rows.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), andbasePackageId= the App.201the first time any member asks,200after. Refusals: a kernel base409(unchanged);no_single_app409; the App namespace held by a legacy pair of a member of the same App409 RESOURCE_CONFLICTnaming it (it stays installed and served; never created beside it); held by any package outside the App422 NAMESPACE_CONFLICT(unchanged).widenPairClosureToAppMembers(protocol-write-package-scope.ts) lets a pair whose closure includes its App reach every installed package whosepairOfis that pair, so an override or anobject_extensionof a module's item saved into the App's pair is admitted. The packaged-base refusal's pending-pair scope uses the same widening.isPairRowRemovalasksisPairOf, so a legacy<module>.customstill removes its own declared override past the sealed-removal refusal; the bundle-member reader (pairsOfAddedMember/findPairAddedMemberRow,protocol-write-gates.ts) reads every pair inpairsOforder; 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.protocol-packageless-overlay-migration.ts): the incomplete-declaration census reads the base from the row's declaredoverrides.packagechecked againstisServedOverrideRowOf, instead of deriving it from the pair id, so a module's override in the App's pair is listed under the module.registry.ts): the refusal for an installed pair reads the pair named for the package amongpairsOf. 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.validate-customization-namespace.ts):namespace-no-customization-pairskips a delivered package whoseappOfis another delivered package.@objectstack/lintgains aworkspace:*dependency on@objectstack/metadata-core.packages/qa/dogfood/test/customization-pair.ts):ensurePairreads the pair id from the door's answer and asserts it.content/docs/concepts/metadata-lifecycle.mdx,content/docs/deployment/install-modes.mdx,content/docs/api/metadata-api.mdx.packages/objectql/src/plugin.tsis not edited by this PR (draft PR feat(objectql)!: a flow, a webhook, a connector and an email template hold one name per package (bare-name phase 1, 3/4) #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 throughpairsOf. When a move ceremony is built, the objects it carries keep theircrm_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 ofpairsOf/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 namespace422; legacy member pair409from the App and every module, legacy unchanged; two-App plugin409; own-unit plugin its own pair201.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 pairapp.objectstack.hotcrm.service.customholding a declared override written straight to the store (a row an earlier version wrote). ThroughHttpDispatcher's catch-all, the override is served at the module's address by name, in the list and in the layered read; throughRestServer'sDELETE /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 equalREAD_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): Appapp.objectstack.hotcrm(namespacecrm) and three modules…service/…revenue/…marketing(typemodule, namespacecrm, each declaring the App^6), registered withengine.registerAppon a SqlDriver:memory:kernel. Ensure viaHttpDispatcher.handlePackagesanswers[[app, 201, app.objectstack.hotcrm.custom], [service, 200, same], [revenue, 200, same], [marketing, 200, same]]with one pair installed. Anobject_extensionaddingsla_tier__cto the module objectcrm_service_caseis saved and published into the App's pair;getMetaItem,getMetaItemLayeredand the registry read it back, its extenders are[app.objectstack.hotcrm.custom], and a record inserted withsla_tier__c: 'gold'stores it.Acceptance notes
the namespace held by a package outside the App: 422 NAMESPACE_CONFLICT still, asked from a moduleand the two older 422 pins); restored → green.system/cloudensure, kernel legacy pair, flow clone for both scopes); restored → green.appOf's single-App answer replaced by the package itself → 3 red (module-first ensure, legacy-pair 409, rehearsal); restored → green.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 sixui/packages/app-shellcall sites that spellcustomizationPackageId(base)(domain:ui); the list-rowcustomizableflag needs a spec sub-card.Verification
Scoped tests
Ablations
Dispatch gates
Derived at
5ac6ed0f(111 commands); the later merge oforigin/main(016eb459) touches none of this diff's files and derives the same list.First pass: 3 gates exited 3 (missing builds / the pinned teardown fixture commit) and
check:test-source-aliasexited 1 (lint needed an anchored@objectstack/metadata-corealias; the rehearsal test needed a module-topimport '@objectstack/metadata'). Both fixed in5ac6ed0f; the prerequisites were built and the four re-run with every changeset, alias and test-input gate, all exit 0.Narrowing follow-up at the final head
775c97b5Contract-review patch round at the final head
fb2fd21eIncremental-review round at the final head
481d4049🤖 Generated with Claude Code