Skip to content

Commit 902f162

Browse files
feat(objectql)!: an API, an object-less action and an engine function hold one name per package (bare-name phase 1, 2/4) (#31006)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DTo6jRMNdsU1vzk7FgXhuZm)_ domain:server#3 · os-elon-musk · session_016WV4d1EuHiCqvh8VEz81QD Refs #30803 (PR 2 of 4 — the request family; PR 1 was #30836; the last PR of the series carries `Fixes`) Clause-②: no (narrowing) — the engine's package door (`SchemaRegistry.installPackage`), item seam (`SchemaRegistry.registerItem`) and hook binder (`bindHooksToEngine`) refuse, after the cold boot, a package registration they accepted before: one whose API or object-less action, or one of whose functions, bears a name another installed package holds. Nothing is widened. Metadata-protocol: narrowed Skills-docs: none — `skills/**` does not teach that two packages may share an API, object-less action or function name, and the naming rule itself is phase 2's (#30805); the prescription ships in the refusal and warning text and in ADR-0048's 2026-11-06 addendum (P1.7). ⚠️ **Tier H**: this diff touches `docs/adr/0048-cross-package-metadata-collision.md` (§3.4 narrowing note, P1.3, P1.5, P1.6 and the new P1.7). The seat routes the approver. ## 维护者速读 - 改了什么:裁决 C 第一阶段的第二族。冷启动结束后,API、无对象 action、引擎函数这三类名字,一个部署只认一个包。API 与无对象 action 在安装门和注册口被拒(与 #30836 的 picklist 相同);函数在 hook-binder 绑定处被拒。拒绝都是 422 `NAMESPACE_CONFLICT`,点出两个持有包和改名建议。 - 为什么:实测两包同名时,端点匹配只留后注册的 API,另一个包的路径直接 404;`/actions/global/NAME` 跑后注册包的代码,`/meta` 却给先注册包的声明;函数注册表只留后注册的函数,flow 的 script 节点与 action 的 target 都按名取、不带包。 - 冷启动:按你 2026-11-06 的裁决 B,已同名的部署照常启动,一条告警点名两个包和改名,开关保持 `'warn'`。告警里原来写“继续服务先注册的那个”,对 API、action 代码与函数不对(它们服务的是后注册的),改成“谁被服务保持现状”。 - 不变的:绑在对象上的 action(`objectName:name` 键)既不算持有者也不被拒;hook 的 `handler` 本来就只在自己包里找函数;同一包重装或热重载;环境覆盖层;直接调用 `ObjectQL.registerFunction`。 - 先看:`bare-name-one-holder.ts`(类型、`holdsBareName` 的无对象过滤、`handlerOwnerPackageId`、按类型生成的拒绝理由)、`registry.ts` 的 `claimBareNameFunctions`、`hook-binder.ts` 的认领调用、ADR-0048 新增 P1.7。 - 需要你做的:ADR 改动是 Tier H,需要审批人批准。 ## What changes - **`packages/objectql/src/bare-name-one-holder.ts`** — the covered types grow from three to six: the registry types `api` and `action` (`BARE_NAME_REGISTRY_TYPES`, `isBareNameRegistryType`) and `function`, which no registry stores. `holdsBareName(type, item)` is the one place an `action` is read as covered: object-less only, through the engine's own owner-key ladder (`standaloneActionOwnerKey` / `isObjectLessActionKey`); `declaredBareNames` and the registry ask it. `handlerOwnerPackageId('app:<id>')` reads the runtime's handler owner as the package. `bareNameReason(types)` builds the refusal's reason from the consumers of the types the refusal carries, so a picklist refusal still names a field's picklist and an API refusal names the endpoint matcher. - **`packages/objectql/src/registry.ts`** — - the holders (`bareNamePackageHolders`) skip a composite slot whose item is not covered (a bound action), and read function claims alone for `function`; - `claimBareNameFunctions(owner, names)` (public): before the seal it records each name's claim; once sealed it refuses, ahead of recording anything, a bind bringing a function another package holds — every conflict in one `BareNameConflictError`, the rename carried — and lets a package re-bind its own; - the item seam judges `isBareNameRegistryType(type) && holdsBareName(type, item)`; - the seal reads function claims with the registry's slots; its reading names the first-registered holder as the existing one (unchanged for the data family, whose first-registered holder is the served one); - the cold-boot warning says the boot "changes nothing about which holder each caller is served" instead of "keeps serving the first-registered holder", which is false for an API, an object-less action's handler and a function (served last-registered). - **`packages/objectql/src/hook-binder.ts`** — `bindHooksToEngine` asks `registry.claimBareNameFunctions(opts.packageId, …)` with the callable entries of `opts.functions` before its registration loop, so a refused bind registers and binds nothing. `@throws` added to its docblock. - **`packages/objectql/src/plugin.ts`** — two comments name the six types and the binder's door; no code change. - **`docs/adr/0048-cross-package-metadata-collision.md`** (Tier H) — §3.4's 2026-11-06 note now names the six types and the binder; P1.3 records the maintainer's cold-boot ruling (warn; only a new holder refused) and the warning's corrected sentence; P1.5 keeps the four-plus-two still to follow and their order; P1.6 names the function door; new **P1.7** — the measurement, what is refused (bound actions excluded, why), the function door and what it does not judge, the cold boot. The header's Addenda entry names the six types. P1.1's heading names the family it covers ("the data family: `picklist`, `mapping`, `dataset`"), beside P1.7's request family. - **`.changeset/30803-bare-name-request-family.md`** — `@objectstack/objectql` `minor`, both declaration lines, BREAKING Before / Now / Who moves / Unchanged, `adr-0087: not-required (no-migration-prescription)`, measured producers. - **`scripts/adr-anchors/packages__objectql__src__bare-name-one-holder.ts.json`** — the anchor's `invariant` now names both families and their served holder (the data family, first-registered; the request family — the endpoint matcher's API, an object-less action's handler, the engine function registry's function — last-registered) and three doors: the package door, the item seam, and the hook binder's bind (`bindHooksToEngine`, through `SchemaRegistry.claimBareNameFunctions`). Same ADR citations (ADR-0048, ADR-0005). - **`.changeset/30803-bare-name-one-package-holder.md`** (PR 1's release note, still pending) — one sentence corrected: a DELIBERATE CORRECTION, below. ## DELIBERATE CORRECTION This PR changes a changeset it did not add: `.changeset/30803-bare-name-one-package-holder.md`, PR 1's note (#30836), which no release has consumed yet. `Check Changeset` (`check-empty-changeset`) refuses that by design and stays red; the dispatching seat confirms the correction by its own record. One sentence of the note's **The cold boot** paragraph changes: - Before: "Whether that boot should be refused instead is the maintainer's open call; the switch (`BARE_NAME_COLD_BOOT_MODE`, `'warn'`) sits in `bare-name-one-holder.ts`." - After: "The maintainer ruled this on 2026-11-06: the boot warns, and only a new holder is refused; the switch (`BARE_NAME_COLD_BOOT_MODE`, `'warn'`) sits in `bare-name-one-holder.ts`." Why: the maintainer ruled the question on 2026-11-06 (ruling B, record 6687840782 on the card), and this PR's own changeset records that ruling; both notes ship in one CHANGELOG release, so the older one would publish a question as still open. The note's sentence "the first-registered holder keeps being served" stays as it is: it is true for its family (picklist, mapping, dataset). ## Why (measured, stage 1) Booted showcase + two `AppPlugin` packages sharing one name per type, both registration orders, on `c17719dd` (`stage1-measurement.md` in the seat's project folder): | Type | Consumer | Served | Warned | |:--|:--|:--|:--| | `api` | endpoint matcher (kernel `metadata` service, one API per name) | last-registered: its path `200`, the other package's path `404` | no | | object-less `action` | `POST /actions/global/NAME` vs `GET /meta/action/NAME` | handler: last-registered; declaration: first-registered | the handler bind ("replaces"), naming neither package | | function | engine function registry (`resolveFunction`), read by flow `script` nodes and action `target` refs | last-registered | no | | CONTROL object-bound `action` | `POST /actions/OBJ/NAME` on each package's object | each package's own, both orders | — | | CONTROL hook naming a function | `beforeInsert` hook `handler: NAME` | own package, both orders (bind-time resolution) | — | ## Verification - `packages/objectql/src/registry-bare-name-one-holder-request-family.test.ts` (new, 34 cells, real `ObjectKernel` + `ObjectQLPlugin`): for `api` and the object-less `action` the data family's matrix — package door (both holders, rename, no record left), ABLATION before the seal, item seam, nested plugin, same-package reload (CONTROL), uninstall releases, environment registration never judged and never a holder (CONTROL), the cold boot (warns once naming both and the rename; reload keeps both, a third refused naming both; `'refuse'` refuses with the doors' envelope). `action` only: CONTROLs — same-named bound actions on two packages' own objects coexist; a bound action neither holds an object-less action's name nor is refused over one, while a second object-less one is; the item seam judges object-less and lets bound through. `function`: the handler-owner reading; after the seal a bind bringing a held function is refused naming both and the rename and registers neither of its functions (the served one untouched); ABLATION before the seal (both claims coexist, the seal reads them); CONTROLs — the same package re-binding, an owner-less bind, an entry with no callable handler; uninstall releases; the cold boot warns once naming both PACKAGES (never `app:…`) while the last-registered function stays served, and a third package is refused naming both. The reason names only the types the refusal carries; a built-in API name is refused at the package door. - `packages/qa/dogfood/test/bare-name-one-holder-request-family.dogfood.test.ts` (new, booted showcase + two `AppPlugin` packages + `MarketplaceInstallLocalPlugin`): the cold boot boots and logs ONE `WARN` line naming the API, the object-less action and the function, both packages (not `app:com.qa…`) and the renames; `POST /marketplace/install-local` of a third package declaring the held API name is `422 PLUGIN_REGISTER_FAILED` naming both holders and `gamma_…`, the package list unchanged and its path unserved (`404`); a package declaring the held object-less action and the showcase's API name is refused naming both; CONTROL: fresh names and an action bound to its own object under the held action's name install. 4 passed; the data family's dogfood file beside it 4 passed. - Ablations (src): the engine plugin's seal call removed → 17 of the 34 unit cells red, 17 green; the binder's claim call removed → the 3 binder-path function cells red; `holdsBareName` forced to `true` → the 3 bound-action CONTROLs red. Each restored. - `@objectstack/objectql` full suite: 561 files / 11239 tests passed; `@objectstack/runtime` full suite: 553 files / 10160 passed, 19 skipped (the binder's callers); data-family pins and `hook-binder.test.ts` 100 passed beside the new file. `pnpm --filter @objectstack/objectql typecheck` exit 0, including `check:test-typecheck` (the new file compiles under `tsconfig.test.json`, not ledgered: its function fixtures are typed `HookHandler`, 0990e5d); `@objectstack/dogfood` `tsc --noEmit` 0 errors. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 74 derived families, all run, every exit 0; `--ran` reconciliation "74 derived famil(ies) accounted for — 74 run, 0 NOT-MEASURED". None needed a rerun: the two prerequisites PR 1 met (`check-plugin-teardown-shape --self-test`'s pinned fixture commit on the shallow clone, `check:dual-build-cjs-loads`'s seven dists) were in place before the run. - Ablation (dist): the new dogfood pin against objectql built from the merge base's four sources → 3 of 4 red (install-local answered `200` and installed `gamma` / `delta`; no warning line), the fresh-names CONTROL green; 4 of 4 green after rebuilding the PR's dist. - Full dogfood battery (`pnpm turbo run test --filter=@objectstack/dogfood`, under `os-verify-lock`) at 0990e5d: `VERDICT command-exit 0` · Test Files 428 passed | 3 skipped (431) · Tests 3560 passed | 41 skipped (3601). - Merged `origin/main` (c77dd17, 14 commits, none touching this diff's files; `dispatch-gates --commands` derives the same 74) as bb78644, rebuilt the dogfood graph, and reran the four bare-name files: objectql 3 files / 100 passed, dogfood 2 files / 8 passed. The full suites, the 74 gates and the battery above ran at 0990e5d, before that merge. - Delta at `247853de` (contract review 6690893976: should-fix 1 and 3, plus nit 1; prose only, 3 files, +3 / −3, no code): `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives 82 families for the PR; the 39 that read the three changed files (31 matched by path, 8 whole-tree) ran, each with its exit code recorded: 38 exit 0 — among them `check:adr-anchors` (OK, 111 anchored files), `check-adr-links` (991 links resolve), `check:adr-index`, `check:doc-authoring`, `check-adr-0087-registration --base origin/main`, `check-changeset-no-major`, `check:closed-ceremony-text`, `check-closing-keyword-parity`, `check:doc-formula-expressions` (after the `@objectstack/lint` closure build) — and `check-empty-changeset --base origin/main` exit 1, the DELIBERATE CORRECTION refusal above. `--ran` reconciliation: 82 derived, 39 run, 0 NOT-MEASURED, 43 UNRUN; the 43 read only the code and test paths this delta leaves untouched, ran at `0990e5dd` (74 of 74 exit 0), and CI is green at `bb786448`. No suite was rerun. ## Kept - Object-bound actions keyed `objectName:name`, jobs keyed `appId:jobName`, hooks resolving their own package's functions; every data-family pin (`registry-bare-name-one-holder.test.ts`, its dogfood file) green unchanged. ## Series and out of scope - PR3 (`flow`, `webhook`, `connector`, `email_template`) and PR4 (`agent`, `skill`, `Fixes #30803`) follow this one; ADR-0048 P1.5 lists them in that order (the maintainer kept all of them in #30803). - A direct `ObjectQL.registerFunction` call is not judged: it is the host's imperative registration outside any package's bind. Judging it would mean the engine's function-registry facet (`engine-hooks-and-functions.ts`), which #30020's facade work rewrites; the binder is the one door a package's functions pass. - An object-less action whose name a BOUND action of another package shares: the bound one is not a holder, and the router's rung-2 declaration read for that object's route was not measured here. ## Deviations - A function's door is the hook binder, not the registry's two doors: functions are code a runtime module brings, never a manifest collection, so `install-local` (no runtime module) can bring none, and its post-boot door is pinned in-process only. A refused bind is logged by `AppPlugin` (`Failed to bind declarative hooks`, `error`) and leaves that package's other registrations as they are. - The runtime's handler owner `app:<packageId>` is read inside objectql (`handlerOwnerPackageId`) so a refusal names the package; the dogfood pin asserts the warning names `com.qa.bare.*` and never `app:`, which pins the two spellings together across the package boundary. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request (second of a series by type family, per the seat). - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer brief is in Chinese by the seat's convention). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. <!-- signed: domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0f920b9 commit 902f162

10 files changed

Lines changed: 986 additions & 103 deletions

‎.changeset/30803-bare-name-one-package-holder.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Skills-docs: none — `skills/**` does not teach that two packages may share a p
1515
- **Why.** A field's `picklist: NAME`, an import's `mappingName` and a dashboard's `datasetName` are resolved with no package. Measured on a booted showcase with two packages sharing one bare name per type, in both registration orders: the picklist served both packages' objects the first-registered package's options (the other package's own values refused `VALIDATION_FAILED`), the other package's import answered `400 MAPPING_TARGET_MISMATCH`, and a dataset query ran the first-registered package's dataset. Each answer flipped with registration order, and nothing warned.
1616
- **Before.** Two installed packages could each ship a picklist, mapping or dataset under one name, at boot or by a later install; every caller was served whichever registered first.
1717
- **Now.** After the cold boot (the engine plugin seals the registry at `kernel:bootstrapped`, once every package of the boot has registered), a package registration declaring such a name another installed package holds is refused ahead of every mutation, so it leaves no record behind. The envelope is ADR-0112's: `code: 'NAMESPACE_CONFLICT'` (`NAMESPACE_CONFLICT_CODE`, exported) and `status: 422`; `install-local`'s inline import answers `422 PLUGIN_REGISTER_FAILED` with this message. The message lists every conflicting name, names both holders, and carries the rename that clears it: the incoming package's namespace prefixed to the name (`beta_status`). The thrown error carries `conflicts[]` with `{ type, name, incomingPackageId, existingHolder, rename }`. A third package declaring a name two packages already hold is refused naming both.
18-
- **The cold boot.** A deployment whose installed packages already share such a name still boots: one warning line names every such name, both holders and the rename, and the first-registered holder keeps being served. Whether that boot should be refused instead is the maintainer's open call; the switch (`BARE_NAME_COLD_BOOT_MODE`, `'warn'`) sits in `bare-name-one-holder.ts`.
18+
- **The cold boot.** A deployment whose installed packages already share such a name still boots: one warning line names every such name, both holders and the rename, and the first-registered holder keeps being served. The maintainer ruled this on 2026-11-06: the boot warns, and only a new holder is refused; the switch (`BARE_NAME_COLD_BOOT_MODE`, `'warn'`) sits in `bare-name-one-holder.ts`.
1919
- **Who moves.** An author whose package declares a picklist, mapping or dataset under a name another package installed beside it already ships, at the next install or upgrade into a running deployment. **The one-line fix: rename the item in one of the two packages — the refusal names the namespace-prefixed name — or uninstall one of them.** A field, import or dashboard that named the old name must name the new one; nothing rewrites stored references.
2020
- **Unchanged.** The same package registering its own name again (a reload, a re-install, an upgrade, including a package that came through the boot beside another holder). An environment item under a package's name: a registration with no package (every `sys_metadata` hydration and metadata write-through) is never judged, and the environment catalog is never a holder — it is the ADR-0005 overlay. A picklist extension of another package's list. Every other metadata type, which keeps ADR-0048 §3.4's coexistence (`page/home` in two packages, object-bound actions keyed `objectName:name`, jobs keyed `appId:jobName`). `OS_METADATA_COLLISION=warn` does not downgrade the refusal. A kernel that withholds the boot-phase hooks never seals, so its doors never refuse.
2121

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
feat(objectql)!: an API, an object-less action and an engine function hold one name per package — once the cold boot has sealed the registry, a package declaring an API or object-less action, or binding a function, under a name another installed package holds is refused, naming both holders and the rename; a boot that already holds such a clash warns
6+
7+
Clause-②: no (narrowing) — the engine's package door (`SchemaRegistry.installPackage`), item seam (`SchemaRegistry.registerItem`) and hook binder (`bindHooksToEngine`) refuse, after the cold boot, a package registration they accepted before: one whose API or object-less action, or one of whose functions, bears a name another installed package holds. Nothing is widened.
8+
Metadata-protocol: narrowed
9+
Skills-docs: none — `skills/**` does not teach that two packages may share an API, object-less action or function name, and the naming rule itself is phase 2's (#30805); the prescription ships in the refusal and warning text and in ADR-0048's 2026-11-06 addendum (P1.7).
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) the refusal removes no key, export or field and changes the shape of no stored body; what an author does about a refused name is pick a different one, which no conversion can choose for them -->
12+
13+
**BREAKING** accept-set narrowing at the package registration door, the second family of maintainer ruling C on #30798, phase 1 (the first was picklists, mappings and datasets): managed install (`install-local`, a post-start `manifest.register`, a hot reload) and package registration (`ObjectQL.registerApp` and every package-bound `registerItem`), for APIs and object-less actions; and the hook binder's function bind, for functions. ADR-0048 §3.4 narrowed (addendum 2026-11-06, P1.7).
14+
15+
- **Why.** Measured on a booted showcase with two packages sharing one bare name per type, in both registration orders: the endpoint matcher kept one API per name, so the other package's declared path answered `404`; `/actions/global/NAME` ran the last-registered package's object-less action while `/meta/action/NAME` served the first-registered package's declaration; and the engine's function registry kept the last-registered function, which a flow `script` node's `function` and an action's `target` resolve with no package. Each answer flipped with registration order; only the action's handler bind warned, naming neither package.
16+
- **Before.** Two installed packages could each ship an API or an object-less action under one name, or bind a function under one name, at boot or by a later install; every caller was served one of them, chosen by registration order.
17+
- **Now.** After the cold boot (the engine plugin seals the registry at `kernel:bootstrapped`, once every package of the boot has registered and bound its functions), a package registration declaring an API or object-less action under a name another installed package holds is refused ahead of every mutation, and a hook-binder bind bringing a function another package holds is refused ahead of every registration and bind it would make. The envelope is the first family's: `code: 'NAMESPACE_CONFLICT'`, `status: 422`, `install-local` answering `422 PLUGIN_REGISTER_FAILED`, every conflicting name in one refusal, both holders named, the rename (the incoming package's namespace prefixed) carried, `conflicts[]` structured. A function's holder is named as its package, never as the runtime's handler owner `app:<packageId>`. A refused function bind is logged by `AppPlugin` at `error` and leaves the package's other registrations as they are.
18+
- **The cold boot.** A deployment whose installed packages already share such a name still boots: the one warning line names every such name, both holders and the rename, and changes nothing about which holder each caller is served. The maintainer ruled this on 2026-11-06: the boot warns, and only a new holder is refused (`BARE_NAME_COLD_BOOT_MODE` stays `'warn'`). The warning's sentence about what is served now reads "changes nothing about which holder each caller is served" for every family, since an API, an object-less action's handler and a function are served last-registered.
19+
- **Who moves.** An author whose package declares an API or object-less action, or binds a function, under a name another package installed beside it already ships, at the next install or upgrade into a running deployment. **The one-line fix: rename the item in one of the two packages — the refusal names the namespace-prefixed name — or uninstall one of them.** A client, flow or action that named the old name must name the new one; nothing rewrites stored references.
20+
- **Unchanged.** An action bound to an object (`objectName`, or a collector's `object`): keyed `objectName:name` from declaration to handler, it neither holds a name nor is refused over one. A hook's `handler`, which resolves among its own package's functions only. The same package registering or binding its own name again. An environment item under a package's name (never judged, never a holder: the ADR-0005 overlay). A bind naming no owner, a function entry with no callable handler, and a direct `ObjectQL.registerFunction` call. `OS_METADATA_COLLISION=warn` does not downgrade the refusal.
21+
22+
**Measured producers.** On objectstack `1aeeafd9`, the four examples carry 3 APIs, 1 object-less action and 3 functions (`app-showcase`: 3 APIs, 1 object-less action, 2 functions; `app-todo`: 1 function), and no name has more than one holder. HotCRM `6961ab1` (five packages, one artifact) declares no API, no object-less action and no function in its stack (its 40 actions are all bound to objects). Measured separately: no name coincides across the two sets. Deployed and marketplace packages, and functions a runtime module registers outside the stack definition, NOT MEASURED.

0 commit comments

Comments
 (0)