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

Merged
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership
Sep 2, 2026
Merged

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
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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_NAMESPACES and isShareableNamespace are untouched, and two packages from different artifacts sharing a namespace are still refused with the existing NamespaceConflictError.

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 crm both try to create crm_account and the second fails at the DB"). So installPackage now 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.
  • Raised ahead of every mutation installPackage makes, which puts it ahead of all DDL: the refused package leaves no record, no namespace claim and no half-applied install behind.
  • One resolution, not a private spelling: the name is resolved through computeFQN, the same call registerObject makes on the very definitions the manifest is about to register.
  • One carve-out, matching 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() to resolveArtifactPackageOrder to ObjectQL.registerApp). The two options a reviewer would weigh:

  1. an install scope threaded from the load path (taken), and
  2. the gate consulting the artifact's own package list — which is the same fact, and the load path is the only layer that holds it, so consulting it means being handed it.

Reasons for this shape:

  • No manifest field. ADR-0130 D8 defers owner/publisher deliberately, 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.
  • Nothing persisted. A co-ownership claim cannot outlive the artifact that IS the claim.
  • The single-package path is bit-identical, structurally. Every caller that installs one package — protocol.installPackage, POST /packages, a bare registerApp — passes no scope, so the co-owner set is empty and both halves are no-ops; a single-manifest artifact 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.
  • One derivation of the package key. The scope names packages through artifactPackageId (id || name), extracted from resolveArtifactPackageOrder and 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:

  • A co-owner is admitted into the namespace but takes over nothing: the sitting package record is still its own manifest, the namespace gained an owner rather than changing hands, and the object stays with the package that declared it.
  • A different id in the same artifact gains no right to redefine an installed co-owner's object — that is the D3 refusal, and it is what "no overwrite right" means concretely.
  • Same-id reinstall still passes, with no scope, with a one-element scope, and with a multi-package scope.

Duplicate manifest.id inside one artifact is deliberately NOT re-implemented here.resolveArtifactPackageOrder already refuses it at load time (DUPLICATE_ARTIFACT_PACKAGE, 422, landed in #14240), before installPackage is ever called, so this gate never sees that input.

OS_METADATA_COLLISION=warn downgrades 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 trap restore on EXIT INT TERM with absolute paths. The subject is source-resolved: the suite imports ./registry.js and ./plugin.js relatively from inside packages/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 plain Error fires one step later. A bare toThrow() 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. The OS_METADATA_COLLISION=warn pin stayed green here as predicted, since warn downgrades the namespace refusal and lets the D3 check run.

Restores verified by bytes both times: git diff HEAD empty, worktree blob 3dc40233… equal to the HEAD blob, marker count 0.

Verification

Re-run in full at HEAD bf980cc4, after merging origin/main (which had moved engine.ts and this branch's one generated doc page; the merge used scripts/pm/os-regen-merge.sh and the census page was regenerated from the merged tree as its own commit, never during MERGE state):

  • pnpm --filter @objectstack/objectql exec vitest run255 files, 4400 tests, all passing (includes the 8 existing NamespaceConflictError gate 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's tsconfig.json excludes **/*.test.ts, so the test layer is measured by check:test-typecheck against tsconfig.test.json; the new suite is inside that checked zone (it reported an error there before it was fixed, which is the coverage proof).
  • Runtime envelope suites touching the error vocabulary: 4 files, 69 tests, green.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands re-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 saved turbo run test log), check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (both need a full-repo build). CI measures all five.
  • check:dispatcher-error-vocabulary green with the new classification row for DUPLICATE_ARTIFACT_OBJECT_NAME (verdict boot-refusal, door none): 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 the manifest service's register(). Both HTTP-facing install sites pass no scope at all.
  • content/docs/permissions/system-context.mdx carries line-number re-anchors only, produced by the census generator after this diff shifted lines in engine.ts and registry.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-review attached, PR kept draft pending that review.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 15 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/deployment/environment-variables.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/adding-a-metadata-type.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via installPackage (symbol, a method of class SchemaRegistry))
  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 34 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 33 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 8edc21382faeebf2bb9e1682ebb5e328228eab3f — the merge of head bf980cc498f73075b7ac4ddf848e9f628a4680e2 into base e854a531abc9ee81264a17d0e0b1f41b38f6f03e, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head bf980cc4,无需修正

独立性声明(先说清,不藏)

对树核实(PR 正文的断言,逐条对源码重验)

断言实测
D3 用与 registerObject 同一解析computeFQN(_ns, shortName) 是恒等函数(return shortName),两处调的是同一个函数,不存在私有拼写
D3 拒绝先于 installPackage 的一切写入refuseCoOwnedObjectNameCollisionconst pkg: InstalledPackage = … 之前调用;被拒的包不留记录、不占命名空间
不加 manifest 字段、不持久化ArtifactInstallScope 只是 installPackage 的可选第三参;唯一构造它的调用方是 manifest 服务 register()ordered.map(artifactPackageId)),两条 HTTP 安装门都不传 scope
包键只有一种推导artifactPackageIdid || name)从 artifact-packages.ts 抽出共用,装载排序与门用同一函数
租户作者豁免与 registerObject 一致registry.ts:3840if (isTenantAuthored(owner.definition)) continueregistry.ts:1767 同一谓词
配对门是一个命题✅ 一个 it 内两半:NamespaceConflictErrorundefined 第二包被 DUPLICATE_ARTIFACT_OBJECT_NAME 拒;消融 A/B 各红 5,恰好是预测集
自重装豁免未被加宽(卡面负向断言)✅ 无 scope / 单元素 scope / 多包 scope 三态下同 id 重装仍过;不同 id 无覆写权即 D3 拒绝
OS_METADATA_COLLISION=warn 不降级 D3✅ 有 pin;warn 只降命名空间拒绝

两处判断题,裁定

① 新错误类不出 index。ArtifactInstallScope / ArtifactObjectNameConflictErrorregistry.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_SURFACESdocs/adr/**.claude/**skills/**AGENTS.mdCLAUDE.md)零命中。mergeable_state: clean

收口

随本评论摘除 needs:contract-review,draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14163 关卡;然后按 objectui#7177 卡上已更正的前提派发(objectui 属平台侧)。


Generated by Claude Code

Merged via the queue into main with commit 1dcb995Sep 2, 2026
74 checks passed
@hotlong
hotlong deleted the claude/issue-14163-install-gate-co-ownership branch September 2, 2026 03:43
hotlong pushed a commit that referenced this pull request Sep 2, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D1+D3:installPackage 命名空间闸认同物共同所有者 + 安装期对象名唯一性检查(同 PR 机器不可拆)

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

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

Merged
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership
Sep 2, 2026
Merged

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
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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_NAMESPACES and isShareableNamespace are untouched, and two packages from different artifacts sharing a namespace are still refused with the existing NamespaceConflictError.

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 crm both try to create crm_account and the second fails at the DB"). So installPackage now 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.
  • Raised ahead of every mutation installPackage makes, which puts it ahead of all DDL: the refused package leaves no record, no namespace claim and no half-applied install behind.
  • One resolution, not a private spelling: the name is resolved through computeFQN, the same call registerObject makes on the very definitions the manifest is about to register.
  • One carve-out, matching 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() to resolveArtifactPackageOrder to ObjectQL.registerApp). The two options a reviewer would weigh:

  1. an install scope threaded from the load path (taken), and
  2. the gate consulting the artifact's own package list — which is the same fact, and the load path is the only layer that holds it, so consulting it means being handed it.

Reasons for this shape:

  • No manifest field. ADR-0130 D8 defers owner/publisher deliberately, 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.
  • Nothing persisted. A co-ownership claim cannot outlive the artifact that IS the claim.
  • The single-package path is bit-identical, structurally. Every caller that installs one package — protocol.installPackage, POST /packages, a bare registerApp — passes no scope, so the co-owner set is empty and both halves are no-ops; a single-manifest artifact 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.
  • One derivation of the package key. The scope names packages through artifactPackageId (id || name), extracted from resolveArtifactPackageOrder and 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:

  • A co-owner is admitted into the namespace but takes over nothing: the sitting package record is still its own manifest, the namespace gained an owner rather than changing hands, and the object stays with the package that declared it.
  • A different id in the same artifact gains no right to redefine an installed co-owner's object — that is the D3 refusal, and it is what "no overwrite right" means concretely.
  • Same-id reinstall still passes, with no scope, with a one-element scope, and with a multi-package scope.

Duplicate manifest.id inside one artifact is deliberately NOT re-implemented here.resolveArtifactPackageOrder already refuses it at load time (DUPLICATE_ARTIFACT_PACKAGE, 422, landed in #14240), before installPackage is ever called, so this gate never sees that input.

OS_METADATA_COLLISION=warn downgrades 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 trap restore on EXIT INT TERM with absolute paths. The subject is source-resolved: the suite imports ./registry.js and ./plugin.js relatively from inside packages/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 plain Error fires one step later. A bare toThrow() 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. The OS_METADATA_COLLISION=warn pin stayed green here as predicted, since warn downgrades the namespace refusal and lets the D3 check run.

Restores verified by bytes both times: git diff HEAD empty, worktree blob 3dc40233… equal to the HEAD blob, marker count 0.

Verification

Re-run in full at HEAD bf980cc4, after merging origin/main (which had moved engine.ts and this branch's one generated doc page; the merge used scripts/pm/os-regen-merge.sh and the census page was regenerated from the merged tree as its own commit, never during MERGE state):

  • pnpm --filter @objectstack/objectql exec vitest run255 files, 4400 tests, all passing (includes the 8 existing NamespaceConflictError gate 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's tsconfig.json excludes **/*.test.ts, so the test layer is measured by check:test-typecheck against tsconfig.test.json; the new suite is inside that checked zone (it reported an error there before it was fixed, which is the coverage proof).
  • Runtime envelope suites touching the error vocabulary: 4 files, 69 tests, green.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands re-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 saved turbo run test log), check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (both need a full-repo build). CI measures all five.
  • check:dispatcher-error-vocabulary green with the new classification row for DUPLICATE_ARTIFACT_OBJECT_NAME (verdict boot-refusal, door none): 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 the manifest service's register(). Both HTTP-facing install sites pass no scope at all.
  • content/docs/permissions/system-context.mdx carries line-number re-anchors only, produced by the census generator after this diff shifted lines in engine.ts and registry.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-review attached, PR kept draft pending that review.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 15 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/deployment/environment-variables.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/adding-a-metadata-type.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via installPackage (symbol, a method of class SchemaRegistry))
  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 34 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 33 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 8edc21382faeebf2bb9e1682ebb5e328228eab3f — the merge of head bf980cc498f73075b7ac4ddf848e9f628a4680e2 into base e854a531abc9ee81264a17d0e0b1f41b38f6f03e, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head bf980cc4,无需修正

独立性声明(先说清,不藏)

对树核实(PR 正文的断言,逐条对源码重验)

断言实测
D3 用与 registerObject 同一解析computeFQN(_ns, shortName) 是恒等函数(return shortName),两处调的是同一个函数,不存在私有拼写
D3 拒绝先于 installPackage 的一切写入refuseCoOwnedObjectNameCollisionconst pkg: InstalledPackage = … 之前调用;被拒的包不留记录、不占命名空间
不加 manifest 字段、不持久化ArtifactInstallScope 只是 installPackage 的可选第三参;唯一构造它的调用方是 manifest 服务 register()ordered.map(artifactPackageId)),两条 HTTP 安装门都不传 scope
包键只有一种推导artifactPackageIdid || name)从 artifact-packages.ts 抽出共用,装载排序与门用同一函数
租户作者豁免与 registerObject 一致registry.ts:3840if (isTenantAuthored(owner.definition)) continueregistry.ts:1767 同一谓词
配对门是一个命题✅ 一个 it 内两半:NamespaceConflictErrorundefined 第二包被 DUPLICATE_ARTIFACT_OBJECT_NAME 拒;消融 A/B 各红 5,恰好是预测集
自重装豁免未被加宽(卡面负向断言)✅ 无 scope / 单元素 scope / 多包 scope 三态下同 id 重装仍过;不同 id 无覆写权即 D3 拒绝
OS_METADATA_COLLISION=warn 不降级 D3✅ 有 pin;warn 只降命名空间拒绝

两处判断题,裁定

① 新错误类不出 index。ArtifactInstallScope / ArtifactObjectNameConflictErrorregistry.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_SURFACESdocs/adr/**.claude/**skills/**AGENTS.mdCLAUDE.md)零命中。mergeable_state: clean

收口

随本评论摘除 needs:contract-review,draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14163 关卡;然后按 objectui#7177 卡上已更正的前提派发(objectui 属平台侧)。


Generated by Claude Code

Merged via the queue into main with commit 1dcb995Sep 2, 2026
74 checks passed
@hotlong
hotlong deleted the claude/issue-14163-install-gate-co-ownership branch September 2, 2026 03:43
hotlong pushed a commit that referenced this pull request Sep 2, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D1+D3:installPackage 命名空间闸认同物共同所有者 + 安装期对象名唯一性检查(同 PR 机器不可拆)

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership
Sep 2, 2026
Merged

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
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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_NAMESPACES and isShareableNamespace are untouched, and two packages from different artifacts sharing a namespace are still refused with the existing NamespaceConflictError.

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 crm both try to create crm_account and the second fails at the DB"). So installPackage now 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.
  • Raised ahead of every mutation installPackage makes, which puts it ahead of all DDL: the refused package leaves no record, no namespace claim and no half-applied install behind.
  • One resolution, not a private spelling: the name is resolved through computeFQN, the same call registerObject makes on the very definitions the manifest is about to register.
  • One carve-out, matching 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() to resolveArtifactPackageOrder to ObjectQL.registerApp). The two options a reviewer would weigh:

  1. an install scope threaded from the load path (taken), and
  2. the gate consulting the artifact's own package list — which is the same fact, and the load path is the only layer that holds it, so consulting it means being handed it.

Reasons for this shape:

  • No manifest field. ADR-0130 D8 defers owner/publisher deliberately, 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.
  • Nothing persisted. A co-ownership claim cannot outlive the artifact that IS the claim.
  • The single-package path is bit-identical, structurally. Every caller that installs one package — protocol.installPackage, POST /packages, a bare registerApp — passes no scope, so the co-owner set is empty and both halves are no-ops; a single-manifest artifact 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.
  • One derivation of the package key. The scope names packages through artifactPackageId (id || name), extracted from resolveArtifactPackageOrder and 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:

  • A co-owner is admitted into the namespace but takes over nothing: the sitting package record is still its own manifest, the namespace gained an owner rather than changing hands, and the object stays with the package that declared it.
  • A different id in the same artifact gains no right to redefine an installed co-owner's object — that is the D3 refusal, and it is what "no overwrite right" means concretely.
  • Same-id reinstall still passes, with no scope, with a one-element scope, and with a multi-package scope.

Duplicate manifest.id inside one artifact is deliberately NOT re-implemented here.resolveArtifactPackageOrder already refuses it at load time (DUPLICATE_ARTIFACT_PACKAGE, 422, landed in #14240), before installPackage is ever called, so this gate never sees that input.

OS_METADATA_COLLISION=warn downgrades 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 trap restore on EXIT INT TERM with absolute paths. The subject is source-resolved: the suite imports ./registry.js and ./plugin.js relatively from inside packages/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 plain Error fires one step later. A bare toThrow() 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. The OS_METADATA_COLLISION=warn pin stayed green here as predicted, since warn downgrades the namespace refusal and lets the D3 check run.

Restores verified by bytes both times: git diff HEAD empty, worktree blob 3dc40233… equal to the HEAD blob, marker count 0.

Verification

Re-run in full at HEAD bf980cc4, after merging origin/main (which had moved engine.ts and this branch's one generated doc page; the merge used scripts/pm/os-regen-merge.sh and the census page was regenerated from the merged tree as its own commit, never during MERGE state):

  • pnpm --filter @objectstack/objectql exec vitest run255 files, 4400 tests, all passing (includes the 8 existing NamespaceConflictError gate 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's tsconfig.json excludes **/*.test.ts, so the test layer is measured by check:test-typecheck against tsconfig.test.json; the new suite is inside that checked zone (it reported an error there before it was fixed, which is the coverage proof).
  • Runtime envelope suites touching the error vocabulary: 4 files, 69 tests, green.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands re-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 saved turbo run test log), check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (both need a full-repo build). CI measures all five.
  • check:dispatcher-error-vocabulary green with the new classification row for DUPLICATE_ARTIFACT_OBJECT_NAME (verdict boot-refusal, door none): 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 the manifest service's register(). Both HTTP-facing install sites pass no scope at all.
  • content/docs/permissions/system-context.mdx carries line-number re-anchors only, produced by the census generator after this diff shifted lines in engine.ts and registry.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-review attached, PR kept draft pending that review.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 15 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/deployment/environment-variables.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/adding-a-metadata-type.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via installPackage (symbol, a method of class SchemaRegistry))
  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 34 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 33 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 8edc21382faeebf2bb9e1682ebb5e328228eab3f — the merge of head bf980cc498f73075b7ac4ddf848e9f628a4680e2 into base e854a531abc9ee81264a17d0e0b1f41b38f6f03e, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head bf980cc4,无需修正

独立性声明(先说清,不藏)

对树核实(PR 正文的断言,逐条对源码重验)

断言实测
D3 用与 registerObject 同一解析computeFQN(_ns, shortName) 是恒等函数(return shortName),两处调的是同一个函数,不存在私有拼写
D3 拒绝先于 installPackage 的一切写入refuseCoOwnedObjectNameCollisionconst pkg: InstalledPackage = … 之前调用;被拒的包不留记录、不占命名空间
不加 manifest 字段、不持久化ArtifactInstallScope 只是 installPackage 的可选第三参;唯一构造它的调用方是 manifest 服务 register()ordered.map(artifactPackageId)),两条 HTTP 安装门都不传 scope
包键只有一种推导artifactPackageIdid || name)从 artifact-packages.ts 抽出共用,装载排序与门用同一函数
租户作者豁免与 registerObject 一致registry.ts:3840if (isTenantAuthored(owner.definition)) continueregistry.ts:1767 同一谓词
配对门是一个命题✅ 一个 it 内两半:NamespaceConflictErrorundefined 第二包被 DUPLICATE_ARTIFACT_OBJECT_NAME 拒;消融 A/B 各红 5,恰好是预测集
自重装豁免未被加宽(卡面负向断言)✅ 无 scope / 单元素 scope / 多包 scope 三态下同 id 重装仍过;不同 id 无覆写权即 D3 拒绝
OS_METADATA_COLLISION=warn 不降级 D3✅ 有 pin;warn 只降命名空间拒绝

两处判断题,裁定

① 新错误类不出 index。ArtifactInstallScope / ArtifactObjectNameConflictErrorregistry.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_SURFACESdocs/adr/**.claude/**skills/**AGENTS.mdCLAUDE.md)零命中。mergeable_state: clean

收口

随本评论摘除 needs:contract-review,draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14163 关卡;然后按 objectui#7177 卡上已更正的前提派发(objectui 属平台侧)。


Generated by Claude Code

Merged via the queue into main with commit 1dcb995Sep 2, 2026
74 checks passed
@hotlong
hotlong deleted the claude/issue-14163-install-gate-co-ownership branch September 2, 2026 03:43
hotlong pushed a commit that referenced this pull request Sep 2, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D1+D3:installPackage 命名空间闸认同物共同所有者 + 安装期对象名唯一性检查(同 PR 机器不可拆)

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership
Sep 2, 2026
Merged

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
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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_NAMESPACES and isShareableNamespace are untouched, and two packages from different artifacts sharing a namespace are still refused with the existing NamespaceConflictError.

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 crm both try to create crm_account and the second fails at the DB"). So installPackage now 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.
  • Raised ahead of every mutation installPackage makes, which puts it ahead of all DDL: the refused package leaves no record, no namespace claim and no half-applied install behind.
  • One resolution, not a private spelling: the name is resolved through computeFQN, the same call registerObject makes on the very definitions the manifest is about to register.
  • One carve-out, matching 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() to resolveArtifactPackageOrder to ObjectQL.registerApp). The two options a reviewer would weigh:

  1. an install scope threaded from the load path (taken), and
  2. the gate consulting the artifact's own package list — which is the same fact, and the load path is the only layer that holds it, so consulting it means being handed it.

Reasons for this shape:

  • No manifest field. ADR-0130 D8 defers owner/publisher deliberately, 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.
  • Nothing persisted. A co-ownership claim cannot outlive the artifact that IS the claim.
  • The single-package path is bit-identical, structurally. Every caller that installs one package — protocol.installPackage, POST /packages, a bare registerApp — passes no scope, so the co-owner set is empty and both halves are no-ops; a single-manifest artifact 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.
  • One derivation of the package key. The scope names packages through artifactPackageId (id || name), extracted from resolveArtifactPackageOrder and 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:

  • A co-owner is admitted into the namespace but takes over nothing: the sitting package record is still its own manifest, the namespace gained an owner rather than changing hands, and the object stays with the package that declared it.
  • A different id in the same artifact gains no right to redefine an installed co-owner's object — that is the D3 refusal, and it is what "no overwrite right" means concretely.
  • Same-id reinstall still passes, with no scope, with a one-element scope, and with a multi-package scope.

Duplicate manifest.id inside one artifact is deliberately NOT re-implemented here.resolveArtifactPackageOrder already refuses it at load time (DUPLICATE_ARTIFACT_PACKAGE, 422, landed in #14240), before installPackage is ever called, so this gate never sees that input.

OS_METADATA_COLLISION=warn downgrades 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 trap restore on EXIT INT TERM with absolute paths. The subject is source-resolved: the suite imports ./registry.js and ./plugin.js relatively from inside packages/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 plain Error fires one step later. A bare toThrow() 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. The OS_METADATA_COLLISION=warn pin stayed green here as predicted, since warn downgrades the namespace refusal and lets the D3 check run.

Restores verified by bytes both times: git diff HEAD empty, worktree blob 3dc40233… equal to the HEAD blob, marker count 0.

Verification

Re-run in full at HEAD bf980cc4, after merging origin/main (which had moved engine.ts and this branch's one generated doc page; the merge used scripts/pm/os-regen-merge.sh and the census page was regenerated from the merged tree as its own commit, never during MERGE state):

  • pnpm --filter @objectstack/objectql exec vitest run255 files, 4400 tests, all passing (includes the 8 existing NamespaceConflictError gate 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's tsconfig.json excludes **/*.test.ts, so the test layer is measured by check:test-typecheck against tsconfig.test.json; the new suite is inside that checked zone (it reported an error there before it was fixed, which is the coverage proof).
  • Runtime envelope suites touching the error vocabulary: 4 files, 69 tests, green.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands re-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 saved turbo run test log), check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (both need a full-repo build). CI measures all five.
  • check:dispatcher-error-vocabulary green with the new classification row for DUPLICATE_ARTIFACT_OBJECT_NAME (verdict boot-refusal, door none): 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 the manifest service's register(). Both HTTP-facing install sites pass no scope at all.
  • content/docs/permissions/system-context.mdx carries line-number re-anchors only, produced by the census generator after this diff shifted lines in engine.ts and registry.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-review attached, PR kept draft pending that review.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 15 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/deployment/environment-variables.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/adding-a-metadata-type.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via installPackage (symbol, a method of class SchemaRegistry))
  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 34 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 33 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 8edc21382faeebf2bb9e1682ebb5e328228eab3f — the merge of head bf980cc498f73075b7ac4ddf848e9f628a4680e2 into base e854a531abc9ee81264a17d0e0b1f41b38f6f03e, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head bf980cc4,无需修正

独立性声明(先说清,不藏)

对树核实(PR 正文的断言,逐条对源码重验)

断言实测
D3 用与 registerObject 同一解析computeFQN(_ns, shortName) 是恒等函数(return shortName),两处调的是同一个函数,不存在私有拼写
D3 拒绝先于 installPackage 的一切写入refuseCoOwnedObjectNameCollisionconst pkg: InstalledPackage = … 之前调用;被拒的包不留记录、不占命名空间
不加 manifest 字段、不持久化ArtifactInstallScope 只是 installPackage 的可选第三参;唯一构造它的调用方是 manifest 服务 register()ordered.map(artifactPackageId)),两条 HTTP 安装门都不传 scope
包键只有一种推导artifactPackageIdid || name)从 artifact-packages.ts 抽出共用,装载排序与门用同一函数
租户作者豁免与 registerObject 一致registry.ts:3840if (isTenantAuthored(owner.definition)) continueregistry.ts:1767 同一谓词
配对门是一个命题✅ 一个 it 内两半:NamespaceConflictErrorundefined 第二包被 DUPLICATE_ARTIFACT_OBJECT_NAME 拒;消融 A/B 各红 5,恰好是预测集
自重装豁免未被加宽(卡面负向断言)✅ 无 scope / 单元素 scope / 多包 scope 三态下同 id 重装仍过;不同 id 无覆写权即 D3 拒绝
OS_METADATA_COLLISION=warn 不降级 D3✅ 有 pin;warn 只降命名空间拒绝

两处判断题,裁定

① 新错误类不出 index。ArtifactInstallScope / ArtifactObjectNameConflictErrorregistry.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_SURFACESdocs/adr/**.claude/**skills/**AGENTS.mdCLAUDE.md)零命中。mergeable_state: clean

收口

随本评论摘除 needs:contract-review,draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14163 关卡;然后按 objectui#7177 卡上已更正的前提派发(objectui 属平台侧)。


Generated by Claude Code

Merged via the queue into main with commit 1dcb995Sep 2, 2026
74 checks passed
@hotlong
hotlong deleted the claude/issue-14163-install-gate-co-ownership branch September 2, 2026 03:43
hotlong pushed a commit that referenced this pull request Sep 2, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D1+D3:installPackage 命名空间闸认同物共同所有者 + 安装期对象名唯一性检查(同 PR 机器不可拆)

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

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

Merged
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership
Sep 2, 2026
Merged

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
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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_NAMESPACES and isShareableNamespace are untouched, and two packages from different artifacts sharing a namespace are still refused with the existing NamespaceConflictError.

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 crm both try to create crm_account and the second fails at the DB"). So installPackage now 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.
  • Raised ahead of every mutation installPackage makes, which puts it ahead of all DDL: the refused package leaves no record, no namespace claim and no half-applied install behind.
  • One resolution, not a private spelling: the name is resolved through computeFQN, the same call registerObject makes on the very definitions the manifest is about to register.
  • One carve-out, matching 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() to resolveArtifactPackageOrder to ObjectQL.registerApp). The two options a reviewer would weigh:

  1. an install scope threaded from the load path (taken), and
  2. the gate consulting the artifact's own package list — which is the same fact, and the load path is the only layer that holds it, so consulting it means being handed it.

Reasons for this shape:

  • No manifest field. ADR-0130 D8 defers owner/publisher deliberately, 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.
  • Nothing persisted. A co-ownership claim cannot outlive the artifact that IS the claim.
  • The single-package path is bit-identical, structurally. Every caller that installs one package — protocol.installPackage, POST /packages, a bare registerApp — passes no scope, so the co-owner set is empty and both halves are no-ops; a single-manifest artifact 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.
  • One derivation of the package key. The scope names packages through artifactPackageId (id || name), extracted from resolveArtifactPackageOrder and 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:

  • A co-owner is admitted into the namespace but takes over nothing: the sitting package record is still its own manifest, the namespace gained an owner rather than changing hands, and the object stays with the package that declared it.
  • A different id in the same artifact gains no right to redefine an installed co-owner's object — that is the D3 refusal, and it is what "no overwrite right" means concretely.
  • Same-id reinstall still passes, with no scope, with a one-element scope, and with a multi-package scope.

Duplicate manifest.id inside one artifact is deliberately NOT re-implemented here.resolveArtifactPackageOrder already refuses it at load time (DUPLICATE_ARTIFACT_PACKAGE, 422, landed in #14240), before installPackage is ever called, so this gate never sees that input.

OS_METADATA_COLLISION=warn downgrades 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 trap restore on EXIT INT TERM with absolute paths. The subject is source-resolved: the suite imports ./registry.js and ./plugin.js relatively from inside packages/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 plain Error fires one step later. A bare toThrow() 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. The OS_METADATA_COLLISION=warn pin stayed green here as predicted, since warn downgrades the namespace refusal and lets the D3 check run.

Restores verified by bytes both times: git diff HEAD empty, worktree blob 3dc40233… equal to the HEAD blob, marker count 0.

Verification

Re-run in full at HEAD bf980cc4, after merging origin/main (which had moved engine.ts and this branch's one generated doc page; the merge used scripts/pm/os-regen-merge.sh and the census page was regenerated from the merged tree as its own commit, never during MERGE state):

  • pnpm --filter @objectstack/objectql exec vitest run255 files, 4400 tests, all passing (includes the 8 existing NamespaceConflictError gate 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's tsconfig.json excludes **/*.test.ts, so the test layer is measured by check:test-typecheck against tsconfig.test.json; the new suite is inside that checked zone (it reported an error there before it was fixed, which is the coverage proof).
  • Runtime envelope suites touching the error vocabulary: 4 files, 69 tests, green.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands re-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 saved turbo run test log), check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (both need a full-repo build). CI measures all five.
  • check:dispatcher-error-vocabulary green with the new classification row for DUPLICATE_ARTIFACT_OBJECT_NAME (verdict boot-refusal, door none): 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 the manifest service's register(). Both HTTP-facing install sites pass no scope at all.
  • content/docs/permissions/system-context.mdx carries line-number re-anchors only, produced by the census generator after this diff shifted lines in engine.ts and registry.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-review attached, PR kept draft pending that review.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 15 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/deployment/environment-variables.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/adding-a-metadata-type.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via installPackage (symbol, a method of class SchemaRegistry))
  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 34 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 33 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 8edc21382faeebf2bb9e1682ebb5e328228eab3f — the merge of head bf980cc498f73075b7ac4ddf848e9f628a4680e2 into base e854a531abc9ee81264a17d0e0b1f41b38f6f03e, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head bf980cc4,无需修正

独立性声明(先说清,不藏)

对树核实(PR 正文的断言,逐条对源码重验)

断言实测
D3 用与 registerObject 同一解析computeFQN(_ns, shortName) 是恒等函数(return shortName),两处调的是同一个函数,不存在私有拼写
D3 拒绝先于 installPackage 的一切写入refuseCoOwnedObjectNameCollisionconst pkg: InstalledPackage = … 之前调用;被拒的包不留记录、不占命名空间
不加 manifest 字段、不持久化ArtifactInstallScope 只是 installPackage 的可选第三参;唯一构造它的调用方是 manifest 服务 register()ordered.map(artifactPackageId)),两条 HTTP 安装门都不传 scope
包键只有一种推导artifactPackageIdid || name)从 artifact-packages.ts 抽出共用,装载排序与门用同一函数
租户作者豁免与 registerObject 一致registry.ts:3840if (isTenantAuthored(owner.definition)) continueregistry.ts:1767 同一谓词
配对门是一个命题✅ 一个 it 内两半:NamespaceConflictErrorundefined 第二包被 DUPLICATE_ARTIFACT_OBJECT_NAME 拒;消融 A/B 各红 5,恰好是预测集
自重装豁免未被加宽(卡面负向断言)✅ 无 scope / 单元素 scope / 多包 scope 三态下同 id 重装仍过;不同 id 无覆写权即 D3 拒绝
OS_METADATA_COLLISION=warn 不降级 D3✅ 有 pin;warn 只降命名空间拒绝

两处判断题,裁定

① 新错误类不出 index。ArtifactInstallScope / ArtifactObjectNameConflictErrorregistry.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_SURFACESdocs/adr/**.claude/**skills/**AGENTS.mdCLAUDE.md)零命中。mergeable_state: clean

收口

随本评论摘除 needs:contract-review,draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14163 关卡;然后按 objectui#7177 卡上已更正的前提派发(objectui 属平台侧)。


Generated by Claude Code

Merged via the queue into main with commit 1dcb995Sep 2, 2026
74 checks passed
@hotlong
hotlong deleted the claude/issue-14163-install-gate-co-ownership branch September 2, 2026 03:43
hotlong pushed a commit that referenced this pull request Sep 2, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D1+D3:installPackage 命名空间闸认同物共同所有者 + 安装期对象名唯一性检查(同 PR 机器不可拆)

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership
Sep 2, 2026
Merged

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
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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_NAMESPACES and isShareableNamespace are untouched, and two packages from different artifacts sharing a namespace are still refused with the existing NamespaceConflictError.

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 crm both try to create crm_account and the second fails at the DB"). So installPackage now 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.
  • Raised ahead of every mutation installPackage makes, which puts it ahead of all DDL: the refused package leaves no record, no namespace claim and no half-applied install behind.
  • One resolution, not a private spelling: the name is resolved through computeFQN, the same call registerObject makes on the very definitions the manifest is about to register.
  • One carve-out, matching 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() to resolveArtifactPackageOrder to ObjectQL.registerApp). The two options a reviewer would weigh:

  1. an install scope threaded from the load path (taken), and
  2. the gate consulting the artifact's own package list — which is the same fact, and the load path is the only layer that holds it, so consulting it means being handed it.

Reasons for this shape:

  • No manifest field. ADR-0130 D8 defers owner/publisher deliberately, 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.
  • Nothing persisted. A co-ownership claim cannot outlive the artifact that IS the claim.
  • The single-package path is bit-identical, structurally. Every caller that installs one package — protocol.installPackage, POST /packages, a bare registerApp — passes no scope, so the co-owner set is empty and both halves are no-ops; a single-manifest artifact 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.
  • One derivation of the package key. The scope names packages through artifactPackageId (id || name), extracted from resolveArtifactPackageOrder and 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:

  • A co-owner is admitted into the namespace but takes over nothing: the sitting package record is still its own manifest, the namespace gained an owner rather than changing hands, and the object stays with the package that declared it.
  • A different id in the same artifact gains no right to redefine an installed co-owner's object — that is the D3 refusal, and it is what "no overwrite right" means concretely.
  • Same-id reinstall still passes, with no scope, with a one-element scope, and with a multi-package scope.

Duplicate manifest.id inside one artifact is deliberately NOT re-implemented here.resolveArtifactPackageOrder already refuses it at load time (DUPLICATE_ARTIFACT_PACKAGE, 422, landed in #14240), before installPackage is ever called, so this gate never sees that input.

OS_METADATA_COLLISION=warn downgrades 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 trap restore on EXIT INT TERM with absolute paths. The subject is source-resolved: the suite imports ./registry.js and ./plugin.js relatively from inside packages/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 plain Error fires one step later. A bare toThrow() 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. The OS_METADATA_COLLISION=warn pin stayed green here as predicted, since warn downgrades the namespace refusal and lets the D3 check run.

Restores verified by bytes both times: git diff HEAD empty, worktree blob 3dc40233… equal to the HEAD blob, marker count 0.

Verification

Re-run in full at HEAD bf980cc4, after merging origin/main (which had moved engine.ts and this branch's one generated doc page; the merge used scripts/pm/os-regen-merge.sh and the census page was regenerated from the merged tree as its own commit, never during MERGE state):

  • pnpm --filter @objectstack/objectql exec vitest run255 files, 4400 tests, all passing (includes the 8 existing NamespaceConflictError gate 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's tsconfig.json excludes **/*.test.ts, so the test layer is measured by check:test-typecheck against tsconfig.test.json; the new suite is inside that checked zone (it reported an error there before it was fixed, which is the coverage proof).
  • Runtime envelope suites touching the error vocabulary: 4 files, 69 tests, green.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands re-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 saved turbo run test log), check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (both need a full-repo build). CI measures all five.
  • check:dispatcher-error-vocabulary green with the new classification row for DUPLICATE_ARTIFACT_OBJECT_NAME (verdict boot-refusal, door none): 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 the manifest service's register(). Both HTTP-facing install sites pass no scope at all.
  • content/docs/permissions/system-context.mdx carries line-number re-anchors only, produced by the census generator after this diff shifted lines in engine.ts and registry.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-review attached, PR kept draft pending that review.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 15 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/deployment/environment-variables.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/adding-a-metadata-type.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via installPackage (symbol, a method of class SchemaRegistry))
  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 34 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 33 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 8edc21382faeebf2bb9e1682ebb5e328228eab3f — the merge of head bf980cc498f73075b7ac4ddf848e9f628a4680e2 into base e854a531abc9ee81264a17d0e0b1f41b38f6f03e, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head bf980cc4,无需修正

独立性声明(先说清,不藏)

对树核实(PR 正文的断言,逐条对源码重验)

断言实测
D3 用与 registerObject 同一解析computeFQN(_ns, shortName) 是恒等函数(return shortName),两处调的是同一个函数,不存在私有拼写
D3 拒绝先于 installPackage 的一切写入refuseCoOwnedObjectNameCollisionconst pkg: InstalledPackage = … 之前调用;被拒的包不留记录、不占命名空间
不加 manifest 字段、不持久化ArtifactInstallScope 只是 installPackage 的可选第三参;唯一构造它的调用方是 manifest 服务 register()ordered.map(artifactPackageId)),两条 HTTP 安装门都不传 scope
包键只有一种推导artifactPackageIdid || name)从 artifact-packages.ts 抽出共用,装载排序与门用同一函数
租户作者豁免与 registerObject 一致registry.ts:3840if (isTenantAuthored(owner.definition)) continueregistry.ts:1767 同一谓词
配对门是一个命题✅ 一个 it 内两半:NamespaceConflictErrorundefined 第二包被 DUPLICATE_ARTIFACT_OBJECT_NAME 拒;消融 A/B 各红 5,恰好是预测集
自重装豁免未被加宽(卡面负向断言)✅ 无 scope / 单元素 scope / 多包 scope 三态下同 id 重装仍过;不同 id 无覆写权即 D3 拒绝
OS_METADATA_COLLISION=warn 不降级 D3✅ 有 pin;warn 只降命名空间拒绝

两处判断题,裁定

① 新错误类不出 index。ArtifactInstallScope / ArtifactObjectNameConflictErrorregistry.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_SURFACESdocs/adr/**.claude/**skills/**AGENTS.mdCLAUDE.md)零命中。mergeable_state: clean

收口

随本评论摘除 needs:contract-review,draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14163 关卡;然后按 objectui#7177 卡上已更正的前提派发(objectui 属平台侧)。


Generated by Claude Code

Merged via the queue into main with commit 1dcb995Sep 2, 2026
74 checks passed
@hotlong
hotlong deleted the claude/issue-14163-install-gate-co-ownership branch September 2, 2026 03:43
hotlong pushed a commit that referenced this pull request Sep 2, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D1+D3:installPackage 命名空间闸认同物共同所有者 + 安装期对象名唯一性检查(同 PR 机器不可拆)

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership
Sep 2, 2026
Merged

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
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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_NAMESPACES and isShareableNamespace are untouched, and two packages from different artifacts sharing a namespace are still refused with the existing NamespaceConflictError.

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 crm both try to create crm_account and the second fails at the DB"). So installPackage now 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.
  • Raised ahead of every mutation installPackage makes, which puts it ahead of all DDL: the refused package leaves no record, no namespace claim and no half-applied install behind.
  • One resolution, not a private spelling: the name is resolved through computeFQN, the same call registerObject makes on the very definitions the manifest is about to register.
  • One carve-out, matching 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() to resolveArtifactPackageOrder to ObjectQL.registerApp). The two options a reviewer would weigh:

  1. an install scope threaded from the load path (taken), and
  2. the gate consulting the artifact's own package list — which is the same fact, and the load path is the only layer that holds it, so consulting it means being handed it.

Reasons for this shape:

  • No manifest field. ADR-0130 D8 defers owner/publisher deliberately, 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.
  • Nothing persisted. A co-ownership claim cannot outlive the artifact that IS the claim.
  • The single-package path is bit-identical, structurally. Every caller that installs one package — protocol.installPackage, POST /packages, a bare registerApp — passes no scope, so the co-owner set is empty and both halves are no-ops; a single-manifest artifact 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.
  • One derivation of the package key. The scope names packages through artifactPackageId (id || name), extracted from resolveArtifactPackageOrder and 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:

  • A co-owner is admitted into the namespace but takes over nothing: the sitting package record is still its own manifest, the namespace gained an owner rather than changing hands, and the object stays with the package that declared it.
  • A different id in the same artifact gains no right to redefine an installed co-owner's object — that is the D3 refusal, and it is what "no overwrite right" means concretely.
  • Same-id reinstall still passes, with no scope, with a one-element scope, and with a multi-package scope.

Duplicate manifest.id inside one artifact is deliberately NOT re-implemented here.resolveArtifactPackageOrder already refuses it at load time (DUPLICATE_ARTIFACT_PACKAGE, 422, landed in #14240), before installPackage is ever called, so this gate never sees that input.

OS_METADATA_COLLISION=warn downgrades 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 trap restore on EXIT INT TERM with absolute paths. The subject is source-resolved: the suite imports ./registry.js and ./plugin.js relatively from inside packages/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 plain Error fires one step later. A bare toThrow() 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. The OS_METADATA_COLLISION=warn pin stayed green here as predicted, since warn downgrades the namespace refusal and lets the D3 check run.

Restores verified by bytes both times: git diff HEAD empty, worktree blob 3dc40233… equal to the HEAD blob, marker count 0.

Verification

Re-run in full at HEAD bf980cc4, after merging origin/main (which had moved engine.ts and this branch's one generated doc page; the merge used scripts/pm/os-regen-merge.sh and the census page was regenerated from the merged tree as its own commit, never during MERGE state):

  • pnpm --filter @objectstack/objectql exec vitest run255 files, 4400 tests, all passing (includes the 8 existing NamespaceConflictError gate 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's tsconfig.json excludes **/*.test.ts, so the test layer is measured by check:test-typecheck against tsconfig.test.json; the new suite is inside that checked zone (it reported an error there before it was fixed, which is the coverage proof).
  • Runtime envelope suites touching the error vocabulary: 4 files, 69 tests, green.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands re-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 saved turbo run test log), check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (both need a full-repo build). CI measures all five.
  • check:dispatcher-error-vocabulary green with the new classification row for DUPLICATE_ARTIFACT_OBJECT_NAME (verdict boot-refusal, door none): 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 the manifest service's register(). Both HTTP-facing install sites pass no scope at all.
  • content/docs/permissions/system-context.mdx carries line-number re-anchors only, produced by the census generator after this diff shifted lines in engine.ts and registry.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-review attached, PR kept draft pending that review.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 15 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/deployment/environment-variables.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/adding-a-metadata-type.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via installPackage (symbol, a method of class SchemaRegistry))
  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 34 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 33 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 8edc21382faeebf2bb9e1682ebb5e328228eab3f — the merge of head bf980cc498f73075b7ac4ddf848e9f628a4680e2 into base e854a531abc9ee81264a17d0e0b1f41b38f6f03e, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head bf980cc4,无需修正

独立性声明(先说清,不藏)

对树核实(PR 正文的断言,逐条对源码重验)

断言实测
D3 用与 registerObject 同一解析computeFQN(_ns, shortName) 是恒等函数(return shortName),两处调的是同一个函数,不存在私有拼写
D3 拒绝先于 installPackage 的一切写入refuseCoOwnedObjectNameCollisionconst pkg: InstalledPackage = … 之前调用;被拒的包不留记录、不占命名空间
不加 manifest 字段、不持久化ArtifactInstallScope 只是 installPackage 的可选第三参;唯一构造它的调用方是 manifest 服务 register()ordered.map(artifactPackageId)),两条 HTTP 安装门都不传 scope
包键只有一种推导artifactPackageIdid || name)从 artifact-packages.ts 抽出共用,装载排序与门用同一函数
租户作者豁免与 registerObject 一致registry.ts:3840if (isTenantAuthored(owner.definition)) continueregistry.ts:1767 同一谓词
配对门是一个命题✅ 一个 it 内两半:NamespaceConflictErrorundefined 第二包被 DUPLICATE_ARTIFACT_OBJECT_NAME 拒;消融 A/B 各红 5,恰好是预测集
自重装豁免未被加宽(卡面负向断言)✅ 无 scope / 单元素 scope / 多包 scope 三态下同 id 重装仍过;不同 id 无覆写权即 D3 拒绝
OS_METADATA_COLLISION=warn 不降级 D3✅ 有 pin;warn 只降命名空间拒绝

两处判断题,裁定

① 新错误类不出 index。ArtifactInstallScope / ArtifactObjectNameConflictErrorregistry.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_SURFACESdocs/adr/**.claude/**skills/**AGENTS.mdCLAUDE.md)零命中。mergeable_state: clean

收口

随本评论摘除 needs:contract-review,draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14163 关卡;然后按 objectui#7177 卡上已更正的前提派发(objectui 属平台侧)。


Generated by Claude Code

Merged via the queue into main with commit 1dcb995Sep 2, 2026
74 checks passed
@hotlong
hotlong deleted the claude/issue-14163-install-gate-co-ownership branch September 2, 2026 03:43
hotlong pushed a commit that referenced this pull request Sep 2, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D1+D3:installPackage 命名空间闸认同物共同所有者 + 安装期对象名唯一性检查(同 PR 机器不可拆)

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

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

Merged
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership
Sep 2, 2026
Merged

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
hotlong merged 6 commits into
mainfrom
claude/issue-14163-install-gate-co-ownership

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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_NAMESPACES and isShareableNamespace are untouched, and two packages from different artifacts sharing a namespace are still refused with the existing NamespaceConflictError.

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 crm both try to create crm_account and the second fails at the DB"). So installPackage now 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.
  • Raised ahead of every mutation installPackage makes, which puts it ahead of all DDL: the refused package leaves no record, no namespace claim and no half-applied install behind.
  • One resolution, not a private spelling: the name is resolved through computeFQN, the same call registerObject makes on the very definitions the manifest is about to register.
  • One carve-out, matching 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() to resolveArtifactPackageOrder to ObjectQL.registerApp). The two options a reviewer would weigh:

  1. an install scope threaded from the load path (taken), and
  2. the gate consulting the artifact's own package list — which is the same fact, and the load path is the only layer that holds it, so consulting it means being handed it.

Reasons for this shape:

  • No manifest field. ADR-0130 D8 defers owner/publisher deliberately, 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.
  • Nothing persisted. A co-ownership claim cannot outlive the artifact that IS the claim.
  • The single-package path is bit-identical, structurally. Every caller that installs one package — protocol.installPackage, POST /packages, a bare registerApp — passes no scope, so the co-owner set is empty and both halves are no-ops; a single-manifest artifact 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.
  • One derivation of the package key. The scope names packages through artifactPackageId (id || name), extracted from resolveArtifactPackageOrder and 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:

  • A co-owner is admitted into the namespace but takes over nothing: the sitting package record is still its own manifest, the namespace gained an owner rather than changing hands, and the object stays with the package that declared it.
  • A different id in the same artifact gains no right to redefine an installed co-owner's object — that is the D3 refusal, and it is what "no overwrite right" means concretely.
  • Same-id reinstall still passes, with no scope, with a one-element scope, and with a multi-package scope.

Duplicate manifest.id inside one artifact is deliberately NOT re-implemented here.resolveArtifactPackageOrder already refuses it at load time (DUPLICATE_ARTIFACT_PACKAGE, 422, landed in #14240), before installPackage is ever called, so this gate never sees that input.

OS_METADATA_COLLISION=warn downgrades 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 trap restore on EXIT INT TERM with absolute paths. The subject is source-resolved: the suite imports ./registry.js and ./plugin.js relatively from inside packages/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 plain Error fires one step later. A bare toThrow() 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. The OS_METADATA_COLLISION=warn pin stayed green here as predicted, since warn downgrades the namespace refusal and lets the D3 check run.

Restores verified by bytes both times: git diff HEAD empty, worktree blob 3dc40233… equal to the HEAD blob, marker count 0.

Verification

Re-run in full at HEAD bf980cc4, after merging origin/main (which had moved engine.ts and this branch's one generated doc page; the merge used scripts/pm/os-regen-merge.sh and the census page was regenerated from the merged tree as its own commit, never during MERGE state):

  • pnpm --filter @objectstack/objectql exec vitest run255 files, 4400 tests, all passing (includes the 8 existing NamespaceConflictError gate 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's tsconfig.json excludes **/*.test.ts, so the test layer is measured by check:test-typecheck against tsconfig.test.json; the new suite is inside that checked zone (it reported an error there before it was fixed, which is the coverage proof).
  • Runtime envelope suites touching the error vocabulary: 4 files, 69 tests, green.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands re-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 saved turbo run test log), check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (both need a full-repo build). CI measures all five.
  • check:dispatcher-error-vocabulary green with the new classification row for DUPLICATE_ARTIFACT_OBJECT_NAME (verdict boot-refusal, door none): 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 the manifest service's register(). Both HTTP-facing install sites pass no scope at all.
  • content/docs/permissions/system-context.mdx carries line-number re-anchors only, produced by the census generator after this diff shifted lines in engine.ts and registry.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-review attached, PR kept draft pending that review.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 15 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/deployment/environment-variables.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/adding-a-metadata-type.mdx(via SchemaRegistry (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via installPackage (symbol, a method of class SchemaRegistry))
  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 34 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 33 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 8edc21382faeebf2bb9e1682ebb5e328228eab3f — the merge of head bf980cc498f73075b7ac4ddf848e9f628a4680e2 into base e854a531abc9ee81264a17d0e0b1f41b38f6f03e, 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 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

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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

复审:PASS —— head bf980cc4,无需修正

独立性声明(先说清,不藏)

对树核实(PR 正文的断言,逐条对源码重验)

断言实测
D3 用与 registerObject 同一解析computeFQN(_ns, shortName) 是恒等函数(return shortName),两处调的是同一个函数,不存在私有拼写
D3 拒绝先于 installPackage 的一切写入refuseCoOwnedObjectNameCollisionconst pkg: InstalledPackage = … 之前调用;被拒的包不留记录、不占命名空间
不加 manifest 字段、不持久化ArtifactInstallScope 只是 installPackage 的可选第三参;唯一构造它的调用方是 manifest 服务 register()ordered.map(artifactPackageId)),两条 HTTP 安装门都不传 scope
包键只有一种推导artifactPackageIdid || name)从 artifact-packages.ts 抽出共用,装载排序与门用同一函数
租户作者豁免与 registerObject 一致registry.ts:3840if (isTenantAuthored(owner.definition)) continueregistry.ts:1767 同一谓词
配对门是一个命题✅ 一个 it 内两半:NamespaceConflictErrorundefined 第二包被 DUPLICATE_ARTIFACT_OBJECT_NAME 拒;消融 A/B 各红 5,恰好是预测集
自重装豁免未被加宽(卡面负向断言)✅ 无 scope / 单元素 scope / 多包 scope 三态下同 id 重装仍过;不同 id 无覆写权即 D3 拒绝
OS_METADATA_COLLISION=warn 不降级 D3✅ 有 pin;warn 只降命名空间拒绝

两处判断题,裁定

① 新错误类不出 index。ArtifactInstallScope / ArtifactObjectNameConflictErrorregistry.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_SURFACESdocs/adr/**.claude/**skills/**AGENTS.mdCLAUDE.md)零命中。mergeable_state: clean

收口

随本评论摘除 needs:contract-review,draft → ready,arm auto-merge,队列按其要求集落地。落地即 Fixes #14163 关卡;然后按 objectui#7177 卡上已更正的前提派发(objectui 属平台侧)。


Generated by Claude Code

Merged via the queue into main with commit 1dcb995Sep 2, 2026
74 checks passed
@hotlong
hotlong deleted the claude/issue-14163-install-gate-co-ownership branch September 2, 2026 03:43
hotlong pushed a commit that referenced this pull request Sep 2, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0130 D1+D3:installPackage 命名空间闸认同物共同所有者 + 安装期对象名唯一性检查(同 PR 机器不可拆)

2 participants

@hotlong@claude