Repository navigation
Commit 0ebb080
feat(objectql): one package-scoped resolver for behavioural metadata and a shared declared-closure helper (ADR-0150 D3/D4) (#31637)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XU6SjEqQ4aAk9NdivD3NcoK)_
epic:#31527#1 · os-project-manager · session_01FEkQ5ovAHLWY1qohbSBYdw
Fixes #31575
Refs #31527
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:新增一个共享解析器 `resolvePackageScopedName`,按 ADR-0150 D3
回答「这个裸名字指哪个软件包的元数据」:先调用方自己的包,再它声明的依赖闭包(加
`sys`)里唯一的持有者,否则拒绝并列出全部候选;无包调用方先环境项,再全环境唯一持有者,否则拒绝。依赖闭包抽成一个导出函数
`packageDependencyClosure`,保存门(`resolveWritePackageScope`)改用它,两边永不分歧。注册表新增三个只读方法,实现
D4 的「按包绑定覆盖层」:覆盖层只绑定到它定制的那个包的同名项。
- 为什么改:专题 #31527 的第 2 步 R;后续 E、G、S 各读者都只引用这一个解析器,避免各门各写一套规则再漂移。
- 风险与回滚:纯新增。没有任何读者接入,`getItem`
一行未动,阶段一的单持有者拒绝照旧生效,保存门的判定不变(metadata-protocol 全量测试 22591 条全绿)。回滚即还原本
PR。
- 先看:`packages/objectql/src/package-scoped-resolver.ts` 的
`resolvePackageScopedName`(三步与拒绝);`registry.ts` 的
`overlaySlotBinding`(覆盖层绑定规则);`protocol-write-package-scope.ts` 的
`packageDependencyClosure`。
- 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run
src/package-scoped-resolver.test.ts`,预期 33 条全绿,含两条红线负向 pin 与阶段一一致性。
- 需要你做的:无,席位落地(须在 ADR-0150 #31586 由你合并之后)。
## What changes
**`@objectstack/objectql` — `src/package-scoped-resolver.ts` (new),
exported from the package barrel only.**
- `resolvePackageScopedName(registry, { type, name, callerPackageId?,
member? })`, ADR-0150 D3 exactly:
- caller of package P: (1) P's own item (its overlay when one binds to
it, else its composite slot); (2) else the unique holder in P's declared
dependency closure plus every `sys`-namespace package; (3) else
`refused`, naming every candidate.
- caller with no package (`undefined`, `null`, `''` or the
`sys_metadata` sentinel): the environment's own item, else the unique
package holder, else `refused` naming every holder.
- zero holders: `not-found`, never a refusal.
- `PLATFORM_REQUESTED_BARE_NAMES_BY_TYPE` (`permission` →
`guest_portal`, the same list as the CLI's
`PLATFORM_REQUESTED_BARE_NAMES`, keyed by registry type):
environment-wide whatever the caller, one holder.
- `PACKAGE_SCOPED_TYPES`: D1 buckets 2 and 3 as the registry spells them
(24 types). `position` (D7, Q2) and every bucket-1 type are not in it,
and asking for one throws a `TypeError`.
- An overridable baseline (`OVERRIDABLE_BASELINE_PACKAGES`, the email
service's built-in templates) yields to any other candidate of the same
step and answers only when it is the only one, which is how
`bare-name-one-holder.ts` already reads it ("neither holds a name nor is
refused one").
- The refusal is a value, not a throw: `{ status: 'refused', reason,
type, name, callerPackageId, candidates, message }`. `reason` is
`ambiguous-in-closure`, `ambiguous-in-environment` or `outside-closure`.
`candidates` reuse the vocabulary's `BareNameHolder` (`kind: 'package'`)
plus one kind this rule needs, `{ kind: 'environment' }`; `message`
names each with `describeBareNameHolder`. The doors (child G) map it to
`409 RESOURCE_CONFLICT`; nothing is minted here.
- `packageResolutionClosure(registry, P)`: the save door's closure plus
`sys`, exported so a reader can show what P reaches.
- Not on the lean `./core` entry: the module reads
`@objectstack/metadata-protocol`, which ADR-0076 D2 keeps out of it
(built `core.d.ts` carries 0 hits of `resolvePackageScopedName`;
`index.d.ts` carries the export).
**`@objectstack/objectql` — `src/registry.ts`, three read-only
`SchemaRegistry` methods (ADR-0150 D4 row "registry overlay slot").**
- `packageScopedHoldersOf(type, name)`: every package holding the name,
in store order: each composite `PACKAGE:NAME` slot (read off the KEY, as
`getItem`'s prefer-local reads it), then the package a bare-slot entry
is its own stored item of.
- `getPackageScopedItem(type, name, packageId, member?)`: the bare-slot
entry when it binds to `packageId`, else that package's composite slot.
Never another package's overlay, never the environment's item.
- `getEnvironmentScopedItem(type, name, member?)`: the bare-slot entry
when it binds to no package.
- The binding rule (`overlaySlotBinding`), on the entry's `_packageId`
(the stored row's `package_id` column, or the artifact envelope the
hydration grafted, ADR-0010 §3.3):
- package-less (none, `''` or the `sys_metadata` sentinel) and no
package holds the name: the environment's own item (bucket 4);
- package-less and exactly one package holds the name: that holder's
overlay. This is ADR-0150 D3's "a package-less row that bears a
package-held name is a customization, never a second item", read at call
time, so a package installed later makes the row its overlay;
- package-less and two or more packages hold the name: bound to none,
and no environment item either (never a guess about whose customization
it is);
- a composite holder: its overlay; a `BASE.custom` pair whose base is a
composite holder: the base's overlay (ADR-0146 D9); any other package:
its own stored item.
- The registry still stores one bare slot per name and `getItem` still
serves it first to every caller; the per-package slot is how these new
reads bind it. Bundles (`email_template`) bind per member.
**`@objectstack/metadata-protocol` —
`src/protocol-write-package-scope.ts`.**
- `packageDependencyClosure(packageId, registry)`: the walk
(`resolvePackageClosure`, shared with Studio's pickers) plus the
App-pair widening, exactly the body `resolveWritePackageScope` had.
`resolveWritePackageScope` is now that call inside its existing silent
`catch`. Exported from the package root.
**Tests.** `packages/objectql/src/package-scoped-resolver.test.ts` (new,
33 cases), and one census row in
`federated-injected-column-readers.test.ts` (see Acceptance notes).
**Changeset.** `.changeset/31575-package-scoped-resolver.md`: `patch`
for both packages.
## Tests
On head 87d197e (after the one `origin/main` merge) unless a line says
otherwise:
- `pnpm --filter @objectstack/objectql exec vitest run
src/package-scoped-resolver.test.ts`: 33/33. The blocks: D3 with a
caller package (7), D3 with no caller (5), red line 1 (2), red line 2
(3), D3 exceptions (4), the closure is the save door's (1), D4 overlay
slot (6), phase-1 parity (5).
- `pnpm --filter @objectstack/objectql test`: 571/571 files, 11604/11604
tests, exit 0 (the lock's `VERDICT command-exit 0`).
- `pnpm --filter @objectstack/objectql typecheck` (tsc plus
`check:test-typecheck`): exit 0.
- `pnpm --filter @objectstack/metadata-protocol test` (head 54ec46b; no
`packages/metadata-protocol` file changed after it, and the merged
`origin/main` commits touch none): 327 files passed, 4 skipped; 22591
tests passed, 26 skipped; exit 0. This is the save door's unchanged
behaviour: `protocol.package-closure-gate`,
`protocol-publish-drafts-closure`, `protocol.runtime-authoring-gate`,
`protocol.packaged-base-refusal` and the pair-widening suites all run
through the switched `resolveWritePackageScope`.
- `pnpm --filter @objectstack/metadata-protocol typecheck` (same head):
exit 0.
- `pnpm exec eslint --no-inline-config` over the seven touched source
and test files: exit 0.
<details><summary>Ablations (each through
<code>scripts/ablation-replace.mjs</code>: mutation proven on disk, run,
restored to the HEAD blob with <code>git diff HEAD</code>
empty)</summary>
The subject is imported from `src/` (relative import in the test), so no
`dist/` rebuild is in the path.
| # | Red line | Mutation | Red output |
|:--|:--|:--|:--|
| A1 | 1 | closure with two candidates answers the first-registered one
instead of refusing | `× two holders in the closure refuse: no
first-registered pick, even with a package-less row beside them`, `× in
the closure: all three holders, in store order, each in the sentence`;
`AssertionError: expected 'resolved' to be 'refused'`; 2 failed / 31
passed |
| A2 | 1 | environment with two holders answers the first-registered one
| `× two package holders refuse a caller with no package: no
first-registered pick`, `× in the environment: all three holders…`, `× a
platform-requested name…`, `× a package-less row over a name two
packages hold binds to neither…`, `× a row bound to a package that ships
no item…`; 5 failed / 28 passed |
| A3 | 1 | nothing in the closure falls back to an environment-wide
resolution | `× a caller package the registry cannot produce declares
nothing…`, `× refuses, naming the holder, when the name is held only
outside the closure — never an environment-wide guess`, `× a
platform-requested name…`; 3 failed / 30 passed |
| A4 | 2 | a refusal keeps only its first two candidates | `× in the
closure: all three holders…`, `× in the environment: all three
holders…`; `expected [ …(1) ] to deeply equal [ …(2) ]`; 2 failed / 31
passed |
| A5 | D4 | `getPackageScopedItem` takes the bare slot whatever it binds
to (today's `getItem` reading) | `× an overlay of one package's item
never shadows another package's same-named item`, `× a package-less row
over a name two packages hold binds to neither…`, `× a row bound to a
package that ships no item…`; 3 failed / 30 passed |
| A6 | D3 (newer text) | every package-less row is the environment's own
item, whoever holds the name | `× a package-less row under the one
holder's name is that holder's overlay, never a second item`, `× a
package-less row is the environment's own item only while no package
holds the name`, `× a package-less row over a name two packages hold…`,
`× every covered type, one package holder and a package-less row over
its name: the same item…`; 4 failed / 29 passed |
The readings above are the final code's (head after the ADR re-read). An
earlier round on the first head read the same reds; its A3 first attempt
kept the anchor inside its replacement and was refused by the tool
before running (a no-op, not a reading), then re-run with a rewriting
anchor.
</details>
## Acceptance notes
**ADR-0150 Execution plan, readings (a)–(d).**
- **(a) phase-1 one-holder tests stay green.**
`registry-bare-name-one-holder.test.ts` and its request, flow and AI
family files run in the full objectql suite above. The new test's last
case also pins that a second package holder is still refused once
sealed.
- **(b) bare-name behaviour unchanged.** No reader imports the resolver:
`git grep package-scoped-resolver -- packages ui` answers only the
module, its test, the barrel and one comment in `registry.ts`. `getItem`
has no diff. The phase-1 parity block asserts, for all 11 covered
registry types, under a sealed registry, with and without an overlay of
the one holder, for a caller with no package, the holder itself and a
dependent, that the resolver's item IS `getItem`'s item (`toBe`), and
the same for an environment-only item.
- **(c) migration.** None in this PR: no stored shape, no column, no
backfill.
- **(d) declarations.** `Clause-②: no`: the diff adds TypeScript exports
to two published packages (`@objectstack/objectql` `index.d.ts`;
`@objectstack/metadata-protocol` `index.d.ts`,
`packageDependencyClosure`) and changes no spec schema, protocol, SDK,
door request or response. `Metadata-protocol: none`: no authorable value
is accepted or refused differently. ADR-0087: no migration entry owed
(nothing stored or authored changes).
**Red lines 1 and 2.** Pinned by the `red line` blocks and ablated
(A1–A4 above). Every refusal carries every candidate of the step that
refused; `outside-closure` carries every holder there is plus the
environment when it holds one.
**Readings this PR had to make where ADR-0150 is silent (named for the
seat's review).**
1. **ADR re-read.** ADR-0150's head moved to 565c63c while this was
built. Its new D3 paragraph ("a package-less row that bears a
package-held name is a customization, never a second item") changed the
binding rule; this PR follows it (commit "a package-less row over a
package-held name binds to that holder"), and A6 pins it. The rest of D3
and D4 is unchanged; the R row now says the resolver adds `sys` itself,
which it does.
2. A caller with a package whose name only the environment's own item
holds is refused, naming the environment (`outside-closure`), not served
it and not answered not-found. D3 lists the environment item only for a
caller with no package. This is the only reading that neither loosens
nor hides a holder; it is in the report's `open_questions` with the four
axes.
3. A package-less row over a name two packages hold binds to neither
(the newer D3 text speaks of "that one phase-1 holder"; for an uncovered
type with two holders, picking one would be a guess). The
environment-wide answer is then the refusal naming both. Today the
hydration grafts the first holder's envelope onto such a row
(`_packageId`), so it reads as that holder's overlay here, exactly as
the bare slot already serves it; this PR does not change the hydration.
4. A bare-slot row bound to a package that ships no item of the name
counts as that package's own stored item, so with another package's
shipped item the environment-wide answer is a refusal. Phase 1's holder
set never reads the bare slot; the ADR identity (`sys_metadata` unique
on package) does. Pinned by the D4 block.
5. Disabled packages are not filtered, as `getItem` does not filter
them.
6. `rag_pipeline` (D1 bucket 3) has no registry type since ADR-0049
retired the collection, so the type list has 24 entries.
7. An object-bound action is resolved by the registry's `(action, name)`
key only; `(package, objectName, name)` is child E2's key.
**Census row.** `federated-injected-column-readers.test.ts` scans every
string literal in `src/` that equals an injected column name. The type
list's `'queue'` (the metadata type, ADR-0150 D1) matched the ADR-0142
ownership-queue column; the row records it as `not-a-read`, as the same
file already does for `registry.ts`'s and `assignment-rules.ts`'s
`queue`.
**Two copies of the platform-requested list.**
`PLATFORM_REQUESTED_BARE_NAMES_BY_TYPE` here and the CLI's
`PLATFORM_REQUESTED_BARE_NAMES` (keyed by collection) hold the same one
name. objectql cannot import the CLI, and the CLI file is outside this
card's surface. Child P (which retires the CLI's phase-2 finding) is the
natural place to make the CLI read objectql's. class: observation ·
carrier: #31527 child P.
## Verification
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on head 87d197e: 71 families derived; each
run with its exit captured before any pipe: **71 exit 0**. `--ran`
reconciliation: "71 derived famil(ies) accounted for — 71 run, 0
NOT-MEASURED".
- On the first head (a0b690f) `check-plugin-teardown-shape --self-test`
answered exit 3 (shallow clone, its fixture commit missing) and passed
after `git fetch --depth=1` of that commit; on 87d197e it ran green in
the loop. `check:dual-build-cjs-loads` answered exit 3 (prerequisite) on
the first head and exit 0 on 87d197e ("122 published require entry
point(s) across 72 package(s) load").
- Built entries: `packages/objectql/dist/index.d.ts` carries
`resolvePackageScopedName` (2 hits), `dist/core.d.ts` carries none;
`packages/metadata-protocol/dist/index.d.ts` carries
`packageDependencyClosure`.
- Not run locally, owned by CI: `test:repo` (no file of that project
changed), the full lint farm, dogfood (no `!` in the title, no narrowing
arm).
- Record: `pm-orders/epic-31527/31575-gates-ran.txt` in the project
files.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5ovAHLWY1qohbSBYdw)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 18cdcb2 commit 0ebb080
8 files changed
Lines changed: 1109 additions & 7 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- objectql/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
258 | 258 | | |
259 | 259 | | |
260 | 260 | | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
261 | 266 | | |
262 | 267 | | |
263 | 268 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
29 | | - | |
30 | | - | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
31 | 39 | | |
32 | 40 | | |
33 | 41 | | |
| |||
105 | 113 | | |
106 | 114 | | |
107 | 115 | | |
108 | | - | |
109 | | - | |
110 | | - | |
111 | | - | |
112 | | - | |
| 116 | + | |
113 | 117 | | |
114 | 118 | | |
115 | 119 | | |
116 | 120 | | |
117 | 121 | | |
118 | 122 | | |
119 | 123 | | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
440 | 440 | | |
441 | 441 | | |
442 | 442 | | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
443 | 447 | | |
444 | 448 | | |
445 | 449 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
96 | 96 | | |
97 | 97 | | |
98 | 98 | | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
99 | 128 | | |
100 | 129 | | |
101 | 130 | | |
| |||
0 commit comments