feat(objectql): admit same-artifact co-owners at the install gate, and refuse two of them defining one object name (ADR-0130 D1+D3) - #14354
Conversation
…stall-gate-co-ownership
…m the merged tree Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 33 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8edc21382faeebf2bb9e1682ebb5e328228eab3f && git checkout 8edc21382faeebf2bb9e1682ebb5e328228eab3f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e854a531abc9ee81264a17d0e0b1f41b38f6f03e bf980cc498f73075b7ac4ddf848e9f628a4680e2 && git checkout -B drift-repro e854a531abc9ee81264a17d0e0b1f41b38f6f03e && git merge --no-ff bf980cc498f73075b7ac4ddf848e9f628a4680e2
node scripts/docs-audit/affected-docs.mjs --json e854a531abc9ee81264a17d0e0b1f41b38f6f03e
|
复审:PASS —— head
|
| 断言 | 实测 |
|---|---|
D3 用与 registerObject 同一解析 |
✅ computeFQN(_ns, shortName) 是恒等函数(return shortName),两处调的是同一个函数,不存在私有拼写 |
D3 拒绝先于 installPackage 的一切写入 |
✅ refuseCoOwnedObjectNameCollision 在 const pkg: InstalledPackage = … 之前调用;被拒的包不留记录、不占命名空间 |
| 不加 manifest 字段、不持久化 | ✅ ArtifactInstallScope 只是 installPackage 的可选第三参;唯一构造它的调用方是 manifest 服务 register()(ordered.map(artifactPackageId)),两条 HTTP 安装门都不传 scope |
| 包键只有一种推导 | ✅ artifactPackageId(id || name)从 artifact-packages.ts 抽出共用,装载排序与门用同一函数 |
租户作者豁免与 registerObject 一致 |
✅ registry.ts:3840 if (isTenantAuthored(owner.definition)) continue ↔ registry.ts:1767 同一谓词 |
| 配对门是一个命题 | ✅ 一个 it 内两半:NamespaceConflictError 为 undefined 且 第二包被 DUPLICATE_ARTIFACT_OBJECT_NAME 拒;消融 A/B 各红 5,恰好是预测集 |
| 自重装豁免未被加宽(卡面负向断言) | ✅ 无 scope / 单元素 scope / 多包 scope 三态下同 id 重装仍过;不同 id 无覆写权即 D3 拒绝 |
OS_METADATA_COLLISION=warn 不降级 D3 |
✅ 有 pin;warn 只降命名空间拒绝 |
两处判断题,裁定
① 新错误类不出 index。 ArtifactInstallScope / ArtifactObjectNameConflictError 从 registry.ts 导出但未加进 index.ts(本 PR 不动 index)。维持:调用方按 ADR-0112 信封的 code + status 匹配,不按类;词表按 code 分类。minor 的依据是已导出的 SchemaRegistry.installPackage / ObjectQL.registerApp 各多一个可选参数(纯加法),不是这个类。
② 见证物只能是正在安装的产物(D8 边界)。 跨产物声称共有权不在本 PR 打开,与 ADR-0130 D8 推迟 owner 字段一致。正确的窄口径。
文档漂移 advisory:九页零欠账
九页全部经由 SchemaRegistry / ObjectQLPlugin 两个类名锚命中。逐页 grep NamespaceConflictError | namespace exclusiv | OS_METADATA_COLLISION | installPackage( | co-own | DUPLICATE_ARTIFACT:八页零命中;唯一一处 protocol/kernel/index.mdx:324——"registry.installPackage() gates the namespace and records the InstalledPackage"——本 diff 之后仍然成立(门仍闸命名空间;对同物共有者多问的一句对象名问题不在该行的抽象层)。两页 release-owned 只读不碰。无文档改动可欠。
CI(bf980cc4,六个必需上下文按 check-run 名称逐个核)
| 必需上下文 | 结论 |
|---|---|
Lint & Repo Gates |
✅ success (03:15:09) |
TypeScript Type Check |
✅ success |
Test Core |
✅ success — 汇总 + 6 分片全过 |
Dogfood Regression Gate |
✅ success — 汇总 + 3 分片全过 |
Build Core |
✅ success |
Temporal Conformance (live PG + MySQL) |
✅ success |
Governed Surface Queue Guard success;8 个文件对 GOVERNED_SURFACES(docs/adr/**、.claude/**、skills/**、AGENTS.md、CLAUDE.md)零命中。mergeable_state: clean。
收口
随本评论摘除 needs:contract-review,draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14163 关卡;然后按 objectui#7177 卡上已更正的前提派发(objectui 属平台侧)。
Generated by Claude Code
…bject-naming item classes, navigationContributions group semantics and analytics binding Extends #14122 §4's four measured cross-package rules to the nine object-naming item classes a real product split needs, plus the `navigationContributions[].group` semantics and the analytics binding. Same method as §4: two packages installed as same-artifact co-owners of one namespace through the real load path (`manifest.register()` on a booted kernel, ADR-0130 D1 / #14354), one pin per item class, accept and refuse both recorded. Measurement only — no runtime behaviour changes. Each row reads BOTH doors, because §4's four verdicts do not all come from the same one: the authoring gate (`defineStack`'s `validateCrossReferences`, which sees one stack and so cannot tell a co-owner's object from a typo) and the install gate (`registerApp` → `installPackage`, which validates no object reference on any of these classes). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
Fixes #14163
Part of #14122 · ADR-0130 D1 + D3
D3 specifies this pair as a machine constraint rather than an instruction, because it is the only part of ADR-0130 that can reach customer data. Both halves are in this one PR, and one test asserts them as one proposition.
What changed
D1 — the gate's question is corrected.
SchemaRegistry.installPackage's ADR-0048 namespace gate asked "is this the same package id?"; it now asks "are these co-owners within one artifact?".RESERVED_NAMESPACESandisShareableNamespaceare untouched, and two packages from different artifacts sharing a namespace are still refused with the existingNamespaceConflictError.D3 — the guarantee that gate was silently carrying is now checked directly. Namespace exclusivity has been proxying for "no two packages define the same object name" — ADR-0048 §3.2 grounds it on exactly that ("two packages with namespace
crmboth try to createcrm_accountand the second fails at the DB"). SoinstallPackagenow refuses a package whose object name is already owned by a co-owner from the same artifact:ArtifactObjectNameConflictError, an ADR-0112 envelope:code: 'DUPLICATE_ARTIFACT_OBJECT_NAME',status: 422, message naming both packages and the object.installPackagemakes, which puts it ahead of all DDL: the refused package leaves no record, no namespace claim and no half-applied install behind.computeFQN, the same callregisterObjectmakes on the very definitions the manifest is about to register.registerObject's own: a tenant-authored sitting owner is skipped, because the ADR-0029 D9 §6.1 late-install path does not refuse that case either — refusing here would break an install the next call would have completed.How the gate learns "same artifact", and why this shape
An optional third argument on
installPackage:ArtifactInstallScope, carrying the artifact's own package-id list, threaded from the ADR-0130 D4/D5 load path (manifest.register()toresolveArtifactPackageOrdertoObjectQL.registerApp). The two options a reviewer would weigh:Reasons for this shape:
owner/publisherdeliberately, and its objection is that a field restates what the artifact structure already says and can drift from it. A per-install scope cannot: it is derived from the delivery at the moment of delivery.protocol.installPackage,POST /packages, a bareregisterApp— passes no scope, so the co-owner set is empty and both halves are no-ops; a single-manifestartifact passes a one-element scope whose only member is the installing package, which the gate excludes anyway. The existing D7 bit-identity pin covers it and stays green.artifactPackageId(id || name), extracted fromresolveArtifactPackageOrderand now shared with it, so the gate cannot admit or refuse a package under a key nothing else in the path uses.Boundary, stated rather than hidden: the witness is the artifact being installed, so a second artifact can only "claim" co-ownership with a package by also delivering that package. Asserting co-ownership across artifacts is exactly the case ADR-0130 D8 defers to a publisher-keyed field, and this PR does not open it.
The negative assertion (from the card's comments)
Today's gate admits
owner === manifest.id— a package reinstalling itself. Relaxing the namespace predicate must not widen that exemption, and this is pinned in both directions:Duplicate
manifest.idinside one artifact is deliberately NOT re-implemented here.resolveArtifactPackageOrderalready refuses it at load time (DUPLICATE_ARTIFACT_PACKAGE, 422, landed in #14240), beforeinstallPackageis ever called, so this gate never sees that input.OS_METADATA_COLLISION=warndowngrades the ADR-0048 namespace refusal only. The object-name refusal is hard, and pinned as such: the escape hatch exists for a deliberate namespace migration, never for letting two definitions of one object name through.Reverse verification — two ablations, both red as predicted, both restored by bytes
Implementation committed first, so each restore leg had a real reference; each mutation was confirmed on disk before the run (anchor count before, injected-marker count after, and a blob hash differing from the HEAD blob), and each script carried a
traprestore onEXIT INT TERMwith absolute paths. The subject is source-resolved: the suite imports./registry.jsand./plugin.jsrelatively from insidepackages/objectql/src, so vitest transforms the mutated source itself — and each ablation's predicted red is itself the evidence the mutation reached what the test resolves.A — keep the relaxation, delete the object-name check. Predicted red: 5 tests. Observed: 5 failed, 4 passed, exactly the predicted set. The pairing gate failed at its second half,
expected undefined to be 'DUPLICATE_ARTIFACT_OBJECT_NAME'.Worth recording: the line above it,
expect(refused).toBeDefined(), still passed under this ablation —registerObject's own plainErrorfires one step later. A baretoThrow()rejection assertion would therefore have stayed green through the deletion of the entire D3 refusal. That is the ADR-0112 envelope requirement, measured on this change rather than quoted.B — restore the check, revert the relaxation. Predicted red: 5 tests. Observed: 5 failed, 4 passed, exactly the predicted set. The pairing gate failed at its first half,
expected NamespaceConflictError ... to be undefined. TheOS_METADATA_COLLISION=warnpin stayed green here as predicted, sincewarndowngrades the namespace refusal and lets the D3 check run.Restores verified by bytes both times:
git diff HEADempty, worktree blob3dc40233…equal to the HEAD blob, marker count 0.Verification
Re-run in full at HEAD
bf980cc4, after mergingorigin/main(which had movedengine.tsand this branch's one generated doc page; the merge usedscripts/pm/os-regen-merge.shand the census page was regenerated from the merged tree as its own commit, never during MERGE state):pnpm --filter @objectstack/objectql exec vitest run— 255 files, 4400 tests, all passing (includes the 8 existingNamespaceConflictErrorgate tests and the ADR-0130 D5/D7 load-path suite).pnpm --filter @objectstack/objectql --filter @objectstack/runtime run typecheck— green, dependency closures built first. objectql'stsconfig.jsonexcludes**/*.test.ts, so the test layer is measured bycheck:test-typecheckagainsttsconfig.test.json; the new suite is inside that checked zone (it reported an error there before it was fixed, which is the coverage proof).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsre-derived after every change: 62 gates, 57 green, 0 findings. The remaining 5 are prerequisite-unmet, NOT MEASURED, and none is related to this diff:check-engine-split-ratio(shallow clone),check-test-completeness(needs a savedturbo run testlog),check-half-states(no GitHub route from this seat),check:dual-build-cjs-loadsandcheck:type-check-debt(both need a full-repo build). CI measures all five.check:dispatcher-error-vocabularygreen with the new classification row forDUPLICATE_ARTIFACT_OBJECT_NAME(verdictboot-refusal, doornone): the refusal can only fire when an artifact scope naming a second package is passed, and measured on this tree the only caller that builds one is themanifestservice'sregister(). Both HTTP-facing install sites pass no scope at all.content/docs/permissions/system-context.mdxcarries line-number re-anchors only, produced by the census generator after this diff shifted lines inengine.tsandregistry.ts. No prose changed.Clause-2
Expected yes — the accept/reject surface moves (a new install-time refusal, and an admission that was previously refused).
needs:contract-reviewattached, PR kept draft pending that review.🤖 Generated with Claude Code
https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m