Skip to content

feat(metadata-core, metadata-protocol, runtime)!: an App and its modules share one customization pair - #31134

Merged
objectstack-fleet[bot] merged 18 commits into
mainfrom
claude/project-thread-oj61dz
Nov 7, 2026
Merged

objectstack-fleet[bot] merged 18 commits into
mainfrom
claude/project-thread-oj61dz

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

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 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 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 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

Scoped tests
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
Ablations
# 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 8f868ff7c6ba -> 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 (8f868ff7c6ba), 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 8f868ff7c6ba -> 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 (8f868ff7c6ba), 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 16a7bd55e08b -> 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 (16a7bd55e08b), git diff HEAD empty; rebuilt, dist preflight --absent ✓
Dispatch gates

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.

Narrowing follow-up at the final head 775c97b5
changeset commit 775c97b5 (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)
Contract-review patch round at the final head fb2fd21e
should-fix 1 + 2: commits c0172ddd..787f842c, then origin/main merged (fb2fd21e)

ablation · legacy pair's reads (metadata-core pairsOf legacy arm disabled)
  blob 16a7bd55e08b -> 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 cca50ea0d34b -> 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 fb2fd21e, all 111 :: exit 0
dispatch-gates --ran: 111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN :: exit 0

dogfood battery at fb2fd21e (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
Incremental-review round at the final head 481d4049
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 9df68e01d38f -> 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 481d4049
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 481d4049, all 111 :: exit 0; --ran: 111 run, 0 NOT-MEASURED, 0 UNRUN :: exit 0
dogfood battery at 481d4049: Test Files 435 passed | 3 skipped (438) · Tests 3609 passed | 41 skipped (3650) · VERDICT command-exit 0 (2423s)

🤖 Generated with Claude Code

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
…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
…tadata load at collection

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 7, 2026
@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/lint, @objectstack/metadata-core, @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/runtime, touching 50 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/lint/package.json, packages/lint/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

19 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 a5cb4f1bf03293ebbe8033ba4d813bf940575d53.

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

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/lint/package.json, packages/lint/vitest.config.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 63 of 226 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 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 — 45 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 a5cb4f1bf03293ebbe8033ba4d813bf940575d53 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3f118220d9dd50e3566f0e8076cdec10129953ad — the merge of head 481d40492ddfc7af48d6362f844571efc6f44d32 into base a5cb4f1bf03293ebbe8033ba4d813bf940575d53, 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 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

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

…remedies, and the ADR-0087 disposition

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
@objectstack-fleet objectstack-fleet Bot changed the title feat(metadata-core, metadata-protocol): an App and its modules share one customization pair feat(metadata-core, metadata-protocol)!: an App and its modules share one customization pair Nov 7, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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 Clause-②: yes (narrowing) declaration (claim amendment 6707871792).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx

Contract review — CONTRACT_REVIEW_TIER · 2026-11-07T14:48Z

Head-sha: 775c97b55e9e5c06f3c29fc6c0c7d4e96a790637

Scope: the whole non-prose diff (owed by Clause-②: yes (narrowing), claim 6708318947), read against ADR-0146 D3 as amended by #31027 (Decided-here 10–12), rulings 6691022506 / 6691217245, pointer 6693814307, reports 6707849840 / 6708291325. Independent Fable review; the seat read its findings against the code.

Verdict: PASS on conformance, with two should-fixes the seat requires before ACCEPT (patch round on this PR).

Conforms (checked):

  • Unit = App: appOf (metadata-core/src/customization-pair-lookup.ts:146-204) is Decided-here 11 literally (App → itself; kernel → itself; declared Apps, else the walk through declared parents, cycle-safe; several → the namespace holder, else undefined → pairOf no_single_app). The ensure door answers the App's pair for any member; 422 only for an owner outside the App; kernel 409 judged first. Uninstall refusal reads the pair named for the package (objectql/src/registry.ts:7394-7398).
  • The two new 409s are exactly Q11 (no_single_app) and Q12 (same-App legacy owner, guarded by appOf(ownerBase) === appId, so an outsider stays 422 — pinned).
  • No escalation: widenPairClosureToAppMembers (protocol-write-package-scope.ts:55-80) widens only for a pair that declares its App, only over installed packages whose pairOf is this pair; never kernel, never uninstalled.
  • Public surface and changeset: every changed/new @objectstack/metadata-core export is declared; appOf(id, installed) ?? id migration correct; declaration lines, BREAKING banner, one ADR-0087 marker (not-required (no-migration-prescription) defensible under D8). Ablations consistent with the report.

Should-fix 1 — undeclared narrowing of a legacy pair (contradicts Q12 "honoured as today"):

  • protocol-packaged-base-refusal.ts:761-779 isPairRowRemoval still asks the single pairOf, so a legacy member pair (<module>.custom) deleting its own declared override is no longer a pair-row removal (protocol-diff-delete-load.ts:428, :781: intent flips to override-artifact and it loses the pass past the sealed-removal refusal). Thread isPairOf as judgeOverrideWrite does (:1170-1171).
  • Same family: pairOfAddedMember (protocol-write-gates.ts:654-660) and the extensions census in runtime/src/domains/packages.ts:3993 read only the App's pair. Make them read pairsOf / isPairOf, or, if a reader is deliberately App-pair-only, name it in the changeset's BREAKING list.

Should-fix 2 — pin Q12 end to end: one dispatcher-level test that a legacy <module>.custom override row is still served at the module's address (read by name, list and layered) and that the legacy pair can delete its own override, with an ablation (single pairOf back ⇒ red).

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 (validate-customization-namespace.ts:123) is silent for a no-single-App package; @objectstack/lint's new workspace:* dependency on metadata-core is named in the PR body only.

Also on record: PR-body guards red on this head is the seat's own claim record (check:closing-target-claim read the original Claim:'s branch); a consolidated Claim: naming claude/project-thread-oj61dz is now on #29594, and the next push re-runs the guard.

… 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
…earlier version stored

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx

Contract review — delta round 2

Head-sha: fb2fd21eb9d05368b49eb24320d1586b40dac847

Delta review · 2026-11-07T16:32Z

Scope: the patch commits c0172ddddc, f446e8a011, da5e499299 and 787f842cb7. fb2fd21eb9 is a clean merge of main (git show --cc is empty). This is an independent Fable review of the code that implements ADR-0146 Q12 (narrowed review rule of 2026-11-07). The seat checked its two should-fixes against the tree.

Verdict: both round-1 should-fixes are resolved; two new should-fixes before ACCEPT.

Resolved (checked):

  • isPairRowRemoval now asks isPairOf, and the three call sites in protocol-diff-delete-load.ts read it unchanged. pairsOfAddedMember / findPairAddedMemberRow read every pair in pairsOf order. The runtime extensions census iterates pairsOf, and readBaseContributions excludes all pairs.
  • Every remaining single-pairOf( site was judged: the ensure door, fix landing, overlay migration, App-pair closure, import landing, and remedy and message text. Each says where a new customization lands, so each is App-pair by design. No reader that serves or lists customizations is App-pair-only, so the changeset's "No reader is App-pair-only" holds.
  • Nothing is widened. A legacy id joins pairsOf only when <base>.custom is installed, a sibling module stays out, kernel keeps [kernel.custom], and an outsider's .custom never enters. Precedence (App pair first, then legacy) is the same in by-name, list and layered reads.
  • The new test runs real ObjectQL, SQL and protocol behind the real doors (HttpDispatcher reads, RestServer DELETE), with rows seeded straight into the store. The doc ablation goes red for the stated reason.

Should-fix A — changeset: .changeset/29594-app-customization-pair.md omits @objectstack/runtime. Yet packages/runtime/src/domains/packages.ts now changes the census and contributions that GET /packages/:id/customizations answers, and AGENTS.md requires a changeset entry for every published package changed. Add '@objectstack/runtime' to the frontmatter at the level AGENTS.md Post-Task step 3 gives; it rides the same feat, and the header scope list should name it.

Should-fix B — test topology: meta-legacy-pair-served.test.ts builds the protocol with no environmentId. So the environmentId !== undefined block in protocol-diff-delete-load.ts (:449–510), which holds refusePackagedBaseRemoval and its !pairRowRemoval guard (:502), is never entered. The ablation is red only through the repository's intent gate. A regression that drops !pairRowRemoval from that guard would stay green. Give the removal case an environmentId (or run it under both topologies), and add one ablation: drop !pairRowRemoval at the guard ⇒ red.

Nits (optional): nothing pins the legacy branch of pairsOfAddedMember (a bundle member a legacy pair added) or the census reading a legacy pair's extensions, though the changeset asserts both. packages.ts ~:3997 puts if (…) for (…) { on one line.

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.

@objectstack-fleet objectstack-fleet Bot changed the title feat(metadata-core, metadata-protocol)!: an App and its modules share one customization pair feat(metadata-core, metadata-protocol, runtime)!: an App and its modules share one customization pair Nov 7, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx

Contract review — ACCEPT

Head-sha: 481d40492ddfc7af48d6362f844571efc6f44d32

ACCEPT · in-seat delta review · 2026-11-07T17:20Z

Delta since round 2 (fb2fd21eb9): b1ea4bc763 and ef5fdb4439, plus a clean merge of main. This review is in-seat: the changes are to the changeset and the test topology, which implement no contract text (narrowed review rule of 2026-11-07).

  • Should-fix A is fixed. .changeset/29594-app-customization-pair.md names '@objectstack/runtime': minor, and the header scope list and the PR title name runtime.
  • Should-fix B is fixed. Every case in meta-legacy-pair-served.test.ts runs under both topologies (host-config, and environmentId: 'env_hotcrm'), 6/6 green. The new ablation drops !pairRowRemoval from the guard in protocol-diff-delete-load.ts and turns the environment-kernel doc removal red with refusePackagedBaseRemoval's own "sealed against removal" refusal (1 failed / 5 passed). The host-config topology stays green, which is expected. Restored, 6/6 green. The earlier single-pairOf ablation (6709801932) still stands; that code has not changed since.
  • The packages.ts one-line if … for nit is split. The two optional pins are not added, which is accepted.
  • Readings: derived gates 111/111 exit 0 with --ran reconciled; the full dogfood battery passes on 481d40492d (435 files / 3609 cases, VERDICT command-exit 0); CI is green on this head, with one job still running.
  • Correction to round 2's last line: this PR is 1349 lines and touches no Tier H path, so no size gate and no human merge apply. The seat lands it through the merge queue.

All three review rounds are closed: the full Fable review at 775c97b55e, Fable delta round 2 at fb2fd21eb9, and this one. Landing: ready, then auto-merge.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 7, 2026 17:21
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 7, 2026 17:21
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 7, 2026
Merged via the queue into main with commit 486b0a9 Nov 7, 2026
71 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-oj61dz branch November 7, 2026 17:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Customization pairs: every system package shares namespace sys, so only the first can get a .custom pair (422 NAMESPACE_CONFLICT on the rest)

2 participants