Skip to content

feat(metadata-protocol)!: thread the installed-manifest lookup through every pair site; the ensure door creates no pair for a kernel package - #31060

Draft
objectstack-fleet[bot] wants to merge 11 commits into
mainfrom
claude/project-thread-kwumcu
Draft

objectstack-fleet[bot] wants to merge 11 commits into
mainfrom
claude/project-thread-kwumcu

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Clause-②: yes (narrowing) — POST /api/v1/packages/:id/ensure-customization answers a kernel base (scope system / cloud) with 409 RESOURCE_CONFLICT, where it created <id>.custom; a copy of a kernel-shipped permission set with no package named is saved environment-wide (new notice code PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE) where it landed in <kernel id>.custom; the exported pair helpers take the installed-manifest lookup as a required argument.
Metadata-protocol: none

Refs #29594
Ruling-ref: 6691022506
Ruling-ref: 6691217245

维护者速读

  • 改了什么:①所有「这个包的定制包是谁」的问题(pairOf / isPairOf / isServedOverrideRowOf / 写闸 / flow 优先级 等 23 处、14 个文件,连同 cli 与 cloud-connection 的调用方)都改为携带「已安装清单查询」,appOf 仍是恒等,行为不变;②ensure 门对内核包(scope system / cloud)返回 409 RESOURCE_CONFLICT,不再创建 <id>.custom。
  • 为什么改:按裁定 6691022506(定制单位 = App)与 6691217245(本版本内核对象一律不可定制,ensure 门给可读的拒绝而非 422)。查询先穿好,PR 3 才能只改 appOf 一处把模块解析到它的 App。
  • 风险与回滚:已在某些部署上由旧门建好的内核配对包继续安装、继续被读取、继续可写,只拒绝 ensure 调用;内核条目的无包写入与指名内核包的写入,不再被引导去一个建不出来的配对包,恢复为可写;克隆平台权限集(如 member_default)且未指定包时,副本改存为环境级(决策卡已发出,推荐此方案)。回滚即 revert 本 PR。
  • 先看:packages/metadata-protocol/src/protocol-customization-package.ts(内核判定)、packages/metadata-core/src/customization-pair-lookup.ts(查询入口)、packages/objectql/src/protocol-custom-override-effective-read.test.ts 末尾两条钉子。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run --project local src/domains/packages-ensure-customization.test.ts(内核 409、旧配对包保留);pnpm --filter @objectstack/objectql exec vitest run --project local src/protocol-custom-override-effective-read.test.ts(旧内核配对包的覆盖继续生效、两道关闭恢复可写)。
  • 需要你做的:在项目线程里回答「平台权限集克隆副本落在哪里」的决策卡(推荐 A:环境级);PR 3(appOf 翻转、旧配对包容忍、ensure 门回答 App 配对包、HotCRM 演练)在本 PR 合并且 ADR-0146 修订 docs(adr): ADR-0146 D3 — one .custom pair per App, and one platform pair for the kernel suite #31027 合并后开工。

What changes

1. The installed-manifest lookup, threaded through every pair site (behaviour unchanged)

pairOf(base) = customizationPackageId(appOf(base)) landed in #31026 with appOf the identity. Resolving a module to its App (ADR-0146 amendment, merge question 11, #31027) needs the registry's installed manifests (type / dependencies / namespace / scope) at every site that asks "which pair customizes this package?". If only the ensure door resolved the App, the override gate and the served-override read (still identity) would refuse or hide the very override written into the App pair. So this PR threads the lookup everywhere first, with appOf still the identity, and PR 3 becomes a one-place flip.

  • @objectstack/metadata-core: InstalledManifestLookup, installedManifestsOf(registry); appOf, pairOf, isPairOf take the lookup (required, so the compiler names every site that has not passed it).
  • @objectstack/metadata-protocol: installedManifestsOfEngine(engine); isServedOverrideRowOf(row, base, installed), OverrideWriteClosure.installed, the overlay merge, the lock state, the layered/list reads, the per-package layer, the stored-flow slot, the served-overrides census, the package-less migration census, the packaged-base refusal and the write gates. Hosts that read the engine gain the engine getter (the codebase's readEngine pattern).
  • Callers outside it: objectql (plugin, registry uninstall refusal), runtime (customizeInPairRemedy, the per-package customizations read), service-automation (flow precedence, with the objectql registry), service-datasource, cli (offline override declarations: the walked manifests), cloud-connection (install-local: the delivered manifests over the registry's).
  • Proof nothing moves: customization-pair-lookup.test.ts asks every row twice, with nothing installed and with every row installed (the module declaring its App the way HotCRM's modules do), and gets the old derivation; every existing suite of the touched packages is green with the lookup passed.
  • Review nit 3 of refactor(metadata-core): look up a package's customization pair through the App that carries it (pairOf), behaviour unchanged #31026: os validate's customization-namespace advisory skips a pair through isCustomizationPackage. Edge: an id ending in .custom that no base derives (a.custom.custom, bare .custom) is now judged like any package.

2. The kernel verdict on the ensure door (narrowing)

Call Before After
ensure a kernel base (scope system / cloud), no pair yet 201, creates <id>.custom 409 RESOURCE_CONFLICT, nothing created or written
ensure a kernel base whose pair an earlier version created 200, created: false 409 RESOURCE_CONFLICT; the pair stays installed and readable
every read of a kernel item's name with a declared override in that legacy pair the override is served unchanged
a save into that legacy pair declaring the override admitted unchanged (the write gate never asks the ensure door)
package-less save onto an item a kernel package ships (overlay types) 403 NOT_OVERRIDABLE, naming the pair saved package-less (the close has no pair to name; amendment Q10)
save naming the kernel package onto an item it ships 403 NOT_OVERRIDABLE, naming the pair the write it had before #28905
clone a kernel-shipped permission set, no package 200, copy in <kernel id>.custom (created) 200, copy environment-wide, code PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE
clone a kernel-shipped flow, no package 200, copy in <kernel id>.custom 409 RESOURCE_CONFLICT, the ensure door's sentence
any non-kernel base — unchanged

The code. 409 RESOURCE_CONFLICT is the door's existing family for "the base's state rules the pair out" (a writable base, no namespace, a namespace with no pair namespace, a non-SemVer version, a malformed existing pair), every one of which answers RESOURCE_CONFLICT in packages-ensure-customization.test.ts. 403 would misread as an authority problem (even the platform operator is refused), and 422 NAMESPACE_CONFLICT names another condition. No ledger code and no packages/spec edit.

Where. First in customizationPairEnsureRefusal, the one reading of the door's registry-decidable refusals, so the two overlay closes and overlayPairFixOf see the same verdict the door throws.

The "new similar" doors (found by the dogfood battery). The permission-set clone door landed a copy of a kernel-shipped set (member_default, shipped by com.objectstack.plugin-security, scope system) in the source package's pair through the ensure door, so the verdict refused the commonest lock-and-clone path. A clone is a new item, not a customization of the kernel item, so with no package named the copy is now saved environment-wide, exactly as a copy of an environment-authored set is, with its own notice and code (PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE; a Console that does not know the code shows the English sentence, as it does for any unknown code). A named package still lands there. The kernel predicate is one spelling, kernelPackageScope (@objectstack/metadata-protocol), read by the door and the clone door. The flow clone door has no environment-wide landing (an unpackaged flow already needs package), so a kernel-shipped flow gets the ensure door's 409 sentence. This landing is option A of a decision card put to the maintainer; B (refuse, naming package) and C (a kernel pair for clones only) are the alternatives.

Test fixtures. packages-ensure-customization.test.ts and automation-flow-clone-custom-package.test.ts modelled "an installed app" as scope: 'system', which is the kernel's scope (manifest schema: system = cross-project shared identity, cloud = control plane). They now hold the base as code, the way an installed app's packages are held (HotCRM 6961ab1 declares no scope).

Blast radius (measured on origin/main 3fe5f2a)

23 pairOf / isPairOf sites in 14 files; isServedOverrideRowOf 17 non-test callers; judgeOverrideWrite 4 (incl. cli offline and cloud-connection install-local). This PR: 31 source files (+471 / −156), 16 test files (+342 / −123), one changeset; 48 files, +842 / −279 in all.

PR 2 / PR 3 split

  • PR 2 (this): the lookup through every site, appOf the identity; the kernel verdict.
  • PR 3 (after this lands and docs(adr): ADR-0146 D3 — one .custom pair per App, and one platform pair for the kernel suite #31027 merges): appOf resolves App membership from the lookup (Q11); legacy per-package pairs stay served (Q12: isPairOf accepts the App pair or an installed legacy <base>.custom); the ensure door answers the App pair with basePackageId = the App id (the spec describe "packageId: <basePackageId>.custom" stays true); the HotCRM rehearsal.

Findings kept for PR 3: the package-less migration's incomplete-declarations census parses pair→base (customizationBasePackageId) and misses module overrides under the App unit; its census still lists a kernel item's package-less row as movable and the apply step fails it with the kernel sentence (a disposition for it is a contract question); the dogfood helper customization-pair.ts and six ui/packages/app-shell call sites derive <base>.custom client-side; a customizable flag on the package list row would need a spec sub-card.

Verification

Head 1340933c2, base origin/main 3fe5f2a (merges cleanly with today's main, which is 17 commits past that base). Exit codes landed on disk before reading.

Run Result
pnpm typecheck in metadata-core, metadata-protocol, objectql, runtime, cli, cloud-connection, lint, service-automation, service-datasource exit 0 each
pnpm test metadata-core / metadata-protocol / lint / service-datasource / service-automation / cloud-connection / cli / objectql / runtime exit 0 each: 962 / 22489 (+26 skipped) / 8288 / 1279 / 3431 / 931 / 7415 (+9) / 11211 / 8851 (+19) passed
dogfood battery, pnpm turbo run test --filter=@objectstack/dogfood on head 021a8a3 (the later commit adds only test-file warm imports) exit 0: 428 files passed, 3 skipped; 3562 tests passed
eslint --no-inline-config on every changed .ts file exit 0
dispatch-gates --commands (75 families), each run with </dev/null 75 exit 0; reconciled with --ran: 75 derived, 75 run, 0 NOT-MEASURED, 0 UNRUN

Gate notes: check-plugin-teardown-shape --self-test first answered 3 (the pinned fixture commit 621a487 was not in the shallow clone; fetched, then exit 0). check:dual-build-cjs-loads first answered 3 (no full build); after a full package build, exit 0. check:test-source-alias reported four test files this PR edits that reach a lazy import during collection; each gained the module-top warm import it names, then exit 0.

Ablations (scripts/ablation-replace.mjs, restore proven by blob hash == HEAD and an empty git status --porcelain):

  • the kernel check in customizationPairEnsureRefusal disabled (ablation-dist-preflight confirmed the mutation in metadata-protocol/dist): red — the three runtime door pins (system, cloud, the legacy kernel pair) and the two objectql pins (legacy pair served after a refused ensure; the closes leave a kernel item's save open);
  • the kernel landing in resolveDefaultCloneLanding disabled: red — [#29594] a set a kernel package ships copies environment-wide.

The lookup threading needs no ablation: it changes no answer, which every existing suite of the nine packages confirms, and customization-pair-lookup.test.ts pins that both lookups (nothing installed, every row installed) give the old derivation.

Acceptance notes

  • The harness's assignee/reviewer step is skipped: this PR goes through the relay as a draft, and review is requested by the seat.
  • No packages/spec, ui/ or skills/ edit; no ADR edit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8

…site; kernel verdict on the ensure door

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8
…loses reopen; changeset

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8
…plugin's other optional reads' way

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8
…nt-wide, the kernel having no pair

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8
…dited test files (check:test-source-alias)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8
@github-actions github-actions Bot added size/xl 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 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 9 package(s): @objectstack/cli, @objectstack/cloud-connection, @objectstack/lint, @objectstack/metadata-core, @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/runtime, @objectstack/service-automation, @objectstack/service-datasource, touching 105 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 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 23cf11a4b9f3fa8c7291b5d7a308c5d14793b006.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: packageId (4 routes)
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 224 client-bound route-ledger rows — the other 161 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 161: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 57 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 — 70 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 23cf11a4b9f3fa8c7291b5d7a308c5d14793b006 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 23cf11a4b9f3fa8c7291b5d7a308c5d14793b006

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

…t's copy lands environment-wide

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

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ee5587b408d451fe8bc52c83d9b630d4f15852d3
Local-runs: none
Scope: the whole net diff 3fe5f2a025...ee5587b408 (50 files, +850 / −281): packages/metadata-core/src/customization-pair-lookup.ts (+ test), packages/metadata-protocol/src/installed-manifests.ts (new), index.ts, protocol.ts (the six host getters), protocol-class-head.ts, protocol-customization-package.ts (the kernel verdict), protocol-diff-delete-load.ts, protocol-discovery-and-bases.ts, protocol-environment-rows.ts, protocol-lock-state.ts, protocol-meta-layered-reads.ts, protocol-meta-list-reads.ts, protocol-overlay-merge.ts, protocol-package-bindings.ts, protocol-packaged-base-refusal.ts, protocol-packageless-overlay-migration.ts, protocol-registry-hydration.ts, protocol-served-overrides.ts, protocol-write-gates.ts, served-override.ts, stored-flow-slot.ts; packages/objectql/src/plugin.ts, registry.ts; packages/runtime/src/domains/packages.ts, permission-set-clone.ts; packages/services/service-automation/src/flow-precedence.ts, plugin.ts; packages/services/service-datasource/src/plugin.ts; packages/cli/src/utils/override-declarations.ts; packages/cloud-connection/src/marketplace-install-local-plugin.ts; packages/lint/src/validate-customization-namespace.ts; the 16 test files and the dogfood pin; .changeset/29594-pair-lookup-installed-kernel-verdict.md; content/docs/api/metadata-api.mdx, content/docs/permissions/permission-sets.mdx (the docs commit ee5587b4). Read beside it on the head: packages/metadata-protocol/src/package-writability.ts 38–83 (READ_ONLY_PACKAGE_SCOPES, isWritablePackage), protocol-customization-package.ts 274–470 whole (the door's order of judgement, ensurePairOnce), protocol-packaged-base-refusal.ts 400–470 (overlayPairFixOf), packages/runtime/src/domains/automation.ts 2432–2625 (registerAndSaveFlow, resolveCloneTarget), permission-set-clone.ts 300–345 and 490–612 (landing, name-taken, save), new-similar-target.ts header, packages/spec/src/kernel/manifest.zod.ts 636 (scope enum), packages/spec/src/api/package-lifecycle.zod.ts 623–660 (the ensure describe), packages/spec/src/api/error-code-ledger.zod.ts (RESOURCE_CONFLICT rows), every scope: 'system' | 'cloud' declaration under packages/** non-test (35 sites, all platform-delivered), the three PERMISSION_SET_CLONE_* code readers under ui/packages/app-shell, content/docs/concepts/metadata-lifecycle.mdx 185 and 216, content/docs/deployment/install-modes.mdx 112, PR #31027's ADR-0146 text at 2990a3dc (D3 "The unit is the App", D11 merge questions 10, 11, 12), HotCRM 6961ab1 src/*/index.ts manifests (no scope), the check-runs on 1340933c and on ee5587b4.

Inputs read: rulings 6691022506 (2026-11-06T17:24Z, unit = App; (b) readable verdict; ② "always installed whole" recorded as goal, not fact) and 6691217245 (2026-11-06T17:33Z, every kernel object not customizable in step 1; the ensure door refuses a kernel base with the readable verdict, not 422; whitelist next version); the seat's claim amendments 6694004188 (2026-11-06T19:50Z) and 6694069747 (2026-11-06T19:53Z, the re-split: PR 2 = lookup threaded with appOf the identity + the kernel verdict, BREAKING banner, legacy pairs stay served; PR 3 = the appOf flip, legacy pairs, the App pair, the rehearsal; spec/ui/skills fenced); the order dispatch-29594-pr2.md (predates the re-split; the amendment governs) and FRAME.md; the dev's out/29594-pr2-scope-fork.md and out/29594-pr2-kernel-clone-collision.md (option A taken, "a new notice code", decision card open); the os-dev report 6698312513 (2026-11-06T23:20Z); PR 1 #31026 (merged 9ed2ee17) and its review record; PR #31027 (open, unmerged, Q10/Q11/Q12); the PR body at ee5587b4 (Clause-② yes (narrowing), Metadata-protocol: none, Refs #29594, two Ruling-ref lines, before/after table, ablations, gates). The head moved from 1340933c to ee5587b4 during this review (one docs-only commit, read in full). Merge-tree against today's origin/main 022b4e7e: clean.

① Lookup threading — behaviour unchanged

  • The lookup is inert by construction. appOf(packageId, installed) is void installed; return packageId; (customization-pair-lookup.ts 103–106); pairOf and isPairOf only forward installed to it (113–127). So every pairOf(x, L) ≡ customizationPackageId(x) and every isPairOf(p, x, L) ≡ isCustomizationPairOf(p, x) for every lookup L, refusal reasons included (base_id_missing, base_is_customization_package, base_id_invalid all still come from the spec helper). Nit 4 of PR 1 holds: pairOf('x.custom', L) is still refused. The unit table pins exactly this for six shapes × two lookups (nothing installed; every row installed with the HotCRM module declaring its App), plus the refusal rows under both lookups (customization-pair-lookup.test.ts 61–129). TRUE.
  • installedManifestsOf cannot throw on a lookup that answers nothing (customization-pair-lookup.ts 86–92: no registry / no getPackage → NOTHING_INSTALLED; a non-object manifest → undefined), pinned by the six it.each sources (test 76–87). installedManifestsOfEngine(engine) is the same over engine?.registry (installed-manifests.ts 19–22).
  • Every site moved only its argument list. I read all 23 pairOf / isPairOf hunks and the isServedOverrideRowOf callers: discovery 409, environment-rows 121–147 and 295–304, lock-state 149–173, layered 403–417 (sort ranks unchanged), list reads 86 / 200 / 274 / 363 / 534 / 569 / 678 / 894, overlay-merge 96–99 and 327–340, package-bindings 273–282, packaged-base-refusal 165–176 / 195–235 / 430 / 690–700 / 1099–1100 / 1290–1292, packageless-migration 381–397 / 461 / 478 / 616 / 679 / 801, registry-hydration 527, served-overrides 97–118, write-gates 641–659, served-override 88–96, stored-flow-slot 89–97, class-head 186–192, diff-delete-load 428–431 / 1167–1171. Branches, status codes, sentences and sort order are byte-identical around each call. TRUE.
  • The type seam is sound. ServedOverlayAddress (overlay-merge 114–146) and mergePackageAwareOverlay's options (282–299) are discriminated unions: servedOverride without installed does not compile, so no serving read can lose its pair silently; the two callers that pass { packageId } alone (overlay-merge 167, the view-container package order) passed no servedOverride on main either. OverrideWriteClosure.installed is required (packaged-base-refusal 846–852), so the compiler names every caller — which is why cli and cloud-connection are in the diff.
  • Sites whose answer could move — each checked, none moves. cli offline (override-declarations.ts 114–116): the lookup is byId.get(id)?.manifest over the artifact's own walked manifests, no registry, so an offline validate reads the same artifact it already read. cloud-connection install-local (marketplace-install-local-plugin.ts 3823–3829): delivered manifest first, else installedManifestsOf(registry); the registry was already read for dependenciesOf on main. objectql plugin (plugin.ts 3842–3851): installedManifestsOf(this.ql.registry), the registry it already held; built once per call, not per row. Flow precedence (flow-precedence.ts 143–151, 184–191, 261–263, 345–348, 392): the lookup is a fourth required parameter; undeclaredRemedy's sentence is unchanged. Served-override row readers: all 17 non-test callers pass installedManifestsOfEngine(host.engine) or the registry they hold. Write gate (overrideWriteVerdict 1290–1292; judgeOverrideWrite 1099–1100): same pairOf / isPairOf pair as before. Runtime customizeInPairRemedy (packages.ts 676–681) and readPackageCustomizations (3992–3993) — the two deferred PR 1 sites — now go through pairOf; identity, so the remedy sentence and the extensions read are unchanged. Lint 108: isCustomizationPackage(manifest.id) replaces the base_is_customization_package parse — the one edge the body names (a.custom.custom, bare .custom) is a base_id_invalid input that the old parse also did not skip-by-reason, so the advisory's outcome on real manifests is the same.
  • Registries newly held; ordering and boot. No site holds a registry it could not reach before. The protocol facets gain engine on their host Picks and protocol.ts hands it over as a lazy getter (get engine() { return readEngine(); }, six sites 3827–7263): read at call time, never at construction, so no boot-order change. The one genuinely new service read is service-automation/plugin.ts 594–600 objectqlRegistryOf(ctx): ctx.getService('objectql') inside try { } catch { return undefined; }, called at the boot pull (5245) beside the loader set. An absent or not-yet-registered objectql answers "nothing installed" → identity → the same precedence. It introduces no await and no dependency ordering, only an optional read, the same shape as the plugin's flowHistoryReader read. service-datasource (plugin.ts 223–229) refactors its existing safeGetService('objectql') read into objectqlRegistry(); no new read. registry.ts 7337 passes installedManifestsOf(this). So: no new boot read, no ordering change, no registry a caller did not already own. TRUE.

② The kernel verdict

  • What "kernel base" is. kernelPackageScopeOf(row) (protocol-customization-package.ts 162–165) = the installed manifest's scope ∈ READ_ONLY_PACKAGE_SCOPES = ['system', 'cloud'] (package-writability.ts 38). It is a manifest-scope test, not a namespace test: sys is not read, and a project-scoped package of namespace sys would not be kernel. Exported as kernelPackageScope(engine, id) (170–174) for the clone doors. Every scope: 'system' declaration under packages/** non-test (35 sites) is a platform-delivered package: the plugins (plugin-auth, plugin-security, organizations, …), the services, platform-objects, the platform apps (apps/account, apps/setup, apps/studio), the UI contributions of cloud-connection / mcp. HotCRM declares no scope (its five manifests), so it is not kernel.
  • Misclassification. The manifest schema admits scope: 'system' | 'cloud' on any manifest (manifest.zod.ts 636, default project), and no install path stamps, strips or refuses it (grepped registry.ts, marketplace-install-local-plugin.ts, runtime packages.ts: the scope is read as posture, never written). So a vendor App that mis-declares scope: 'system' would be read as kernel and get no pair. That is a pre-existing meaning of the scope — isWritablePackage 80 and isDisableableAppModule (packages.ts 759) already treat it as "the platform's", and the dev's own fixtures had modelled "an installed app" as scope: 'system' and were moved to code (packages-ensure-customization.test.ts 63–69, automation-flow-clone-custom-package.test.ts 68–73). The predicate is the right one for this version; the vendor's remedy (drop the scope) belongs in the changeset's "who moves" (nit 4).
  • 409 RESOURCE_CONFLICT. A standard ledger member (error-code-ledger.zod.ts 2001 shadows: 'RESOURCE_CONFLICT'; error-code-ledger.test.ts 71), already the door's code for a writable base, no namespace, a bad pair namespace, a non-SemVer version and a malformed existing pair (customizationPairEnsureRefusal 363–406; expectRefusal(r, 409, 'RESOURCE_CONFLICT', …) on each in the runtime test). No new ledger row, no packages/spec edit (the file list carries none). The message (353–358): "Package '' is a kernel package (scope ''): kernel objects are not customizable in this version, so '' has no paired customization package and '.custom' is not ensured. A pair an earlier version created stays installed, and the overrides in it stay served (ADR-0146 D3)." Readable, names the base, the scope and the pair id, says "in this version" and nothing about which objects may open later — neutral about the whitelist. TRUE. check:error-status-conformance exit 0 in the dev's gate list.
  • Nothing is created. The verdict is the second judgement in customizationPairEnsureRefusal (after the 404 for an uninstalled base, 341–359) and ensurePairOnce throws whatever that function answers before reading the version or calling installPackage (416–448). ensureCustomizationPackage itself only parses, derives the pair id and takes the in-flight slot before ensurePairOnce (280–330); nothing is written on the refused path. The leavesNothing harness pins it for system and cloud plus getPackage('<id>.custom') === undefined (runtime test 254–266).
  • Legacy kernel pairs. The door's existing branch is below the kernel check, so a legacy <kernel>.custom is neither adopted nor rewritten: the refusal says it stays (354–357) and the pin compares the registry row before/after (structuredClone, runtime test 268–280) and reads it 200. Served: the objectql pin installs the pair under a scope: 'system' base, saves a declared override, gets 409 on ensure, then reads the override on the by-name, package-scoped, layered and list reads (protocol-custom-override-effective-read.test.ts 475–493). Writable: the write gate is judgeOverrideWrite over pairOf / isPairOf — identity, no ensure-door read anywhere in protocol-packaged-base-refusal.ts 1060–1110 — and the same pin lands a second save into the pair (494–496). TRUE.
  • The two closes. overlayPairFixOf (packaged-base-refusal 427–445) answers null when customizationPairEnsureRefusal answers a refusal, and the package-less close and the named-base close then leave the save open — the existing family for every base the door refuses (the docblock at 336–347 says so). For a kernel item that means: package-less save onto a kernel-shipped overlay-type item, 403 NOT_OVERRIDABLE naming the pair on main → admitted now; a save naming the kernel package → admitted (effective-read test 498–515, with an App control that still gets 403). This is a widening, and it is in the body's table but not in the Clause-② line (should-fix 1). Its governing text is PR docs(adr): ADR-0146 D3 — one .custom pair per App, and one platform pair for the kernel suite #31027's Q10 ("its package-less write stays open until the kernel whitelist admits the item into the platform pair", decided where the rulings were silent), which is unmerged; ruling 6691217245 itself says every kernel object is served as not customizable and says nothing about reopening the package-less write D11 closed. Contract question for the seat (should-fix 2), not a code defect: the code follows the ensure-door family as designed.
  • Narrowing declaration. Clause-② yes (narrowing) at line start in both carriers, one each; Metadata-protocol: none (right: no authorable key, value or meaning moves; the ensure response schema is untouched). Changeset: patch on the nine packages whose dist changes; **BREAKING** for a caller that ensures the customization pair of a kernel package … and for a TypeScript importer of the pair helpers; who moves and the one-line remedy ("drops that call …; passes installedManifestsOf(engine.registry), or installedManifestsOf(undefined)") present; adr-0087: not-required comment reasoned. The ! title + patch bumps pass check-changeset-no-major (exit 0 in the gate list) and Check Changeset is green on both heads. The Clause-② line names the ensure door, the clone landing and the retyped helpers; it omits the reopened closes (widening) and the flow clone's new 409 (a second narrowing row in the table) — one sentence (should-fix 1).
  • Flow clone of a kernel-shipped flow. resolveCloneTarget (automation.ts 2608–2618) relays the ensure door's thrown 409 through deps.errorFromThrown, and registerAndSaveFlow 2463–2474 calls restoreFlowRegistration on a refused landing, so the engine registration made before land() is undone and nothing is created. Correct, and in the table; no pin covers that row (should-fix 4).

③ The clone collision (option A)

  • Is the "new notice code" a published spec key? No. PERMISSION_SET_CLONE_NOTICE_CODES lives in packages/runtime/src/domains/permission-set-clone.ts 186–191 and nowhere under packages/spec/** on main or on the head (git grep of all four codes: runtime, dogfood, the Console's reader ui/packages/app-shell/src/views/metadata-admin/permission-set-clone.ts 90–94 and two UI tests, CHANGELOGs). The clone response's noticeCode is an untyped string the Console switches on, with a documented fallback to the served notice for an unknown code (the UI test at StudioDesignSurface.accessNewSimilar-28573.test.tsx 360 pins PERMISSION_SET_CLONE_SOMEWHERE_NEWER → sentence as sent). So the new value PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE is a new runtime value on an existing untyped field, not a new ledger row and not a packages/spec edit; no spec fence is crossed. It is nonetheless a new value a client may switch on — the body declares it in the Clause-② line, the changeset and now permission-sets.mdx 456–460, and the dev's out-of-scope note hands the zh sentence to the ui lane. Flagged here as the seat asked; nothing to stop the PR.
  • Is landing environment-wide safe? The copy takes a new machine name: taken || target.name === source.name → PERMISSION_SET_CLONE_NAME_TAKEN_STATUS after a catalog resolve of the target name (permission-set-clone.ts 497–525), and the save is create-only, with METADATA_CONFLICT re-answered as name-taken (585–595). permission_set is not an overridable type (served-override.test.ts 66: isServedOverrideRowOf is false for it), so an environment-wide row cannot be read as an override of anything, and a same-name shadow is refused by the name check. The grants are the source's, judged as inherited (inheritedGrantObjects, 576–583), unchanged from the environment-authored landing. What changes for the operator is only where the copy lives (no package, so it does not travel with one) — the notice says so and names package as the way to keep it in a package (210–215). The kernel item itself is untouched. A named package still goes through newSimilarTargetRefusal (544–550). No shadow, no override, nothing unintended.
  • Option B instead. resolveDefaultCloneLanding 319–323 is the whole switch: replace the return { packageId: null, created: false, kernelSource } with a thrown refusal (409, naming package=<a writable package of yours>), drop kernelSource from PermissionSetCloneLanding (168–172), the fourth code and its sentence (186–215), the unit pin (permission-set-clone.test.ts 247–261) and the four-code table rows (369–375 → three), re-point the dogfood pin (permission-set-clone-door.dogfood.test.ts 231–248) to the refusal, and revert the permission-sets.mdx sentence. About 60 lines across four files plus the docs line; the kernel verdict and the lookup threading are untouched. Option C would reopen the ensure door for a clone source, against the ruling, and would be a larger change in the door. The maintainer's card is open; A is implemented and reversible.

④ Tests, ablations, dogfood, gates (the dev's readings; Local-runs: none)

  • Lookup: the identity pinned under two lookups × six shapes + refusals (metadata-core 962 green). Kernel verdict: system and cloud 409 with nothing created, legacy pair unchanged and readable (runtime 8851 green); legacy override served on four reads and a further save lands; the two closes reopen for a kernel item with an App control (objectql 11211 green). Clone: unit pin (no ensure, packageId absent on the save, the code and the sentence), the four-code distinctness table, the dogfood pin re-pointed (package: null, the code, GET /packages/<kernel>.custom 404). Adapted suites: 22489 metadata-protocol, 8288 lint, 1279 datasource, 3431 automation, 931 cloud-connection, 7415 cli — all pass with the lookup passed; no test skipped, quarantined or deleted.
  • Ablations (server-lane negative-pin rule): kernel check disabled (dist preflight confirmed) → 3 runtime + 2 objectql pins red; clone landing disabled → its unit pin red; both restored, blob == HEAD, tree clean. The lookup threading has no ablation by design (nothing to red; the table pins both lookups equal). Honest shape.
  • Dogfood battery on 021a8a3d (the two later commits are test warm-imports and docs): first run 1 failed — the very pin that found the collision — final run exit 0, 428 files, 3562 tests. The body says so and names the commit.
  • Gates: dispatch-gates --commands 75 families, --ran 75/75, 0 NOT-MEASURED; three first-pass non-zeros fixed and re-run to 0 (check-plugin-teardown-shape --self-test after fetching the pinned fixture commit; check:dual-build-cjs-loads after a full build; check:test-source-alias with the four warm imports that are the 1340933c commit). eslint --no-inline-config exit 0 on every changed .ts.

⑤ Commits and body

Seven commits over the base 3fe5f2a0: 98e6c224, cca74048, 6c336bab, a5372388, 021a8a3d, 1340933c, ee5587b4. Each trailer is Co-Authored-By: Claude <noreply@anthropic.com> + Claude-Session: …session_01L9qibzCo6pa7SSUFmosQD8; no model name, no Fixes, no bare login. Body: Refs #29594, Ruling-ref: 6691022506, Ruling-ref: 6691217245, Clause-② and Metadata-protocol lines at line start, the 维护者速读 six lines consistent with the diff (the 风险与回滚 line states the reopened closes and the clone landing plainly). The verification table still names head 1340933c2 (should-fix 5).

⑥ Check-runs

On 1340933c (superseded): the push of ee5587b4 cancelled the run; the three failure aggregates (Lint & Repo Gates, Test UI, TypeScript Type Check) carry annotations that read "lane … concluded cancelled -- expected success" — cancellation, no red leg. 23 success before the cancel, among them Check Changeset, Dogfood Regression Gate, PR-body guards, Governed Surface Queue Guard, Lint · ESLint, Lint · PM dispatch-gates, Type Check · source gates, Plan Test Core shards.
On ee5587b4, read at 2026-11-06T23:25Z: 41 check-runs, 19 completed/success (Check Changeset, Check Documentation Links, Dogfood Regression Gate, Flag docs affected by code changes, Governed Surface Queue Guard, PR-body guards, Check PR Size, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Spec property liveness, Auto Label, filter …), 7 skipped, 15 in_progress (Build Core, Build Docs, Build UI Deps, the four Lint · legs, Type Check · workspace / debt ledger / consumer gates / source gates, Plan Test Core shards, Live E2E UI), 0 failed. Test Core 1–8 not yet started. Landing condition: every check completed, 0 failed, re-read from the API; check:dual-build-cjs-loads runs inside Build Core.

⑦ Docs (the moved head)

content/docs/api/metadata-api.mdx 113–116: "a kernel package (scope system or cloud) has no pair in this version and answers 409 RESOURCE_CONFLICT, though a pair an earlier version created keeps serving what it holds" — matches customizationPairEnsureRefusal 350–358 and the legacy-pair pins. content/docs/permissions/permission-sets.mdx 456–460: "A copy of a set the platform itself ships (a kernel package of scope system or cloud, such as member_default) is saved environment-wide, in no package … (noticeCode PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE)" — matches resolveDefaultCloneLanding 319–323 and permissionSetCloneNotice 210–215; the surrounding "<X>.custom … created on the first copy" paragraph now reads as the non-kernel case, which it is. Two pages are left stale (should-fix 3). Check Documentation Links and Flag docs affected by code changes are green on the head.

Findings:

must-fix: none.

should-fix:

  1. Declaration (PR body line 4, changeset line 15). The Clause-② line names the narrowing arms and the retyped helpers but not the widening the diff also makes: a package-less save, or a save naming the kernel package, onto a kernel-shipped overlay-type item moves from 403 NOT_OVERRIDABLE to admitted (effective-read test 498–515; table rows 5–6), and five new re-exported names widen the public faces (installedManifestsOf, InstalledManifestLookup, AppLookupManifest, InstalledPackageSource on @objectstack/metadata-core; kernelPackageScope on @objectstack/metadata-protocol), plus the flow clone's 200 → 409 row. The spelling stays yes (narrowing) (the order: the narrowing governs); the line should say "both arms" and name the widening in one clause. One sentence in each carrier; delta review by this reviewer.
  2. Governing text for the reopened closes (seat decision, not code). The reopening rests on PR docs(adr): ADR-0146 D3 — one .custom pair per App, and one platform pair for the kernel suite #31027 Q10, unmerged and marked "decided where the rulings were silent"; ruling 6691217245 says every kernel object is served as not customizable and is silent on the package-less write D11 closed. The seat records one of: (a) PR 2 lands on the Q10 reading and the record names docs(adr): ADR-0146 D3 — one .custom pair per App, and one platform pair for the kernel suite #31027 as its governing text (the body already makes PR 3 wait for docs(adr): ADR-0146 D3 — one .custom pair per App, and one platform pair for the kernel suite #31027); or (b) the two closes refuse a kernel item without naming a pair until the whitelist version, which is a small change in packagelessOverlayWriteRefusal / namedBaseCloseSubject reading kernelPackageScopeOf. I read (a) as consistent with the door family and with Q10 at CR PASS; it needs the seat's sentence.
  3. Docs left stale (content/docs/** is in the file surface): content/docs/concepts/metadata-lifecycle.mdx 185 says a package-less write onto a ✅-type item one installed read-only package ships is refused naming the pair — not for a kernel item now; 216 says a flow copy lands in <base>.custom "created on first use" — a kernel-shipped flow now answers the ensure door's 409. One clause each, the shape of the two edits already made in ee5587b4.
  4. Pin the flow row. The table's "clone a kernel-shipped flow, no package → 409" has no test: automation-flow-clone-custom-package.test.ts moved its base off scope: 'system' and adds no kernel case, and the ablation reds the ensure door only. One case asserting 409, the relayed sentence and that the engine's flow map holds no copy (restoreFlowRegistration, automation.ts 2463–2474).
  5. Body head. The verification table names 1340933c2; the head is ee5587b4 (docs-only; the suites and the battery were run on 021a8a3d / 1340933c). One line saying so.

nits:

  1. kernelPackageScope (protocol-customization-package.ts 170–174) reads registry.getPackage through its own Partial<PairRegistry> cast beside installedManifestsOfEngine; the same read spelled twice. Could be kernelPackageScopeOf({ manifest: installedManifestsOfEngine(engine)(packageId) }).
  2. appOf's void installed; is the right pin for PR 2, but the "every row installed" half of the unit table proves only that the lookup is ignored; the docblock at customization-pair-lookup.ts 26–34 says so, which is enough — worth one line in the test's docblock so PR 3's reviewer expects the module row to flip.
  3. service-automation/plugin.ts 594–600 swallows every error from getService, not only "not registered"; the sibling reads do the same and the docblock says so. Consistent; leave.
  4. Changeset "who moves": add that a vendor package which declares scope: 'system' or 'cloud' in its own manifest is read as kernel by this predicate (as it already is by isWritablePackage); the remedy is to drop the scope. And "always installed whole" (changeset line 19) is the goal ruling 6691022506 ② recorded, not today's behaviour (serve mounts by tier preset); "ships with the runtime at the runtime's version" is the fact.
  5. The lint hunk's docblock (validate-customization-namespace.ts 36–38) is right; the body's "Edge" sentence overstates it — the old parse never skipped a.custom.custom either (it is base_id_invalid, not base_is_customization_package), so no answer moves.

Implemented-by: claude/project-thread-kwumcu
Reviewed-by: session_01L9qibyvxbrLP4jm4PargoH

VERDICT: PASS — landing waits on: every check on ee5587b4 completed with 0 failed (re-read from the API); the maintainer's answer on the clone decision card (A implemented; B is a ~60-line reversal in four files + one docs line); the seat's sentence on should-fix 2 (the Q10 reading, or a shut close for kernel items until #31027 merges); the two carrier lines of should-fix 1 and the body head line of should-fix 5, delta-reviewed by this reviewer. Should-fix 3 and 4 may ride the same delta or a follow-up the seat names.

…ming no pair

Ruling record 6691217245 serves every kernel object as not customizable in
this version, so the package-less close and the close of a write naming the
base stay shut on a kernel item exactly where they were shut before the
ensure door's kernel verdict: 403 NOT_OVERRIDABLE, with a sentence that
names no pair. The ensure door's other judgements are read with its kernel
verdict left out (judgeKernel: false), and the fix carries kernelScope.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8
…nswers the ensure door's 409

The flow clone door lands the copy in the source's pair through the ensure
door; a kernel package has none in this version, so the door relays the 409
and the registration made before the landing is undone: no copy in the
engine, no row, no pair.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzCo6pa7SSUFmosQD8
…set, and say where a kernel copy lands

The changeset's Clause-② line names both arms: the flow clone's 200 to 409
for a kernel-shipped flow beside the ensure door, and the five new exports
and the new permission-set clone notice code. The closes bullet now says
they stay shut on a kernel item. Who moves names a vendor package that
declares a platform scope. metadata-lifecycle.mdx says a kernel item's
package-less write names no pair, a kernel flow's copy answers 409 until
package is named, and a kernel set's copy lands environment-wide.

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling 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.

1 participant