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

@hotlong hotlong 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 github-actions Bot added the size/l label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

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.

hotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

复审: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:3840 if (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 1dcb995 Sep 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 free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants