Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .changeset/31985-tool-names-per-namespace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
'@objectstack/objectql': minor
'@objectstack/core': minor
'@objectstack/metadata': minor
---

feat(objectql)!: two packages of two namespaces may each ship a tool of the same name, and the metadata service keeps each package's tool under its own (package, name) slot (ADR-0150 child B)

Clause-②: yes (narrowing) — widened: the engine's package door (`SchemaRegistry.installPackage`), its item seam (`SchemaRegistry.registerItem`) and the cold boot's holder seal (`SchemaRegistry.sealBareNameHolders`) accept, for a tool, a second package holder they refused before: one that declares a namespace other than the holder's. Narrowed: `tool` joins `PACKAGE_SLOTTED_STORE_TYPES` (`@objectstack/metadata`) in the same change, as that set's rule requires, so the metadata service's in-process answers for a tool move the way they moved for the other types stored per package: (1) `MetadataManager.get('tool', NAME)` and `getDiagnosed` naming no package, of a name two packages hold, are refused `409 RESOURCE_CONFLICT` (`ambiguous-in-environment`, naming both) where they answered the item registered (or saved) last; (2) `get('tool', NAME, PKG)` and `getDiagnosed`, where `PKG` holds no slot of the name and another package does, answer `undefined` where they answered the stored item (the one holder's, or the survivor of two); (3) `unregister('tool', NAME)` naming no package, of a name two packages hold, is refused the same way before anything is deleted or announced, and so is a rollback of a name whose rows are two packages', or one package's beside the name's package-less row; (4) `DatabaseLoader.delete` naming no package of a name two packages hold is refused, where it deleted the first row; (5) `MetadataManager.getHistory`, `diff` and `rollback` of a tool name two packages saved through `DatabaseLoader`, naming no package, are refused `409 RESOURCE_CONFLICT` naming both, where they answered every version of the one row both saves wrote, and a rollback to a version a removed package wrote is refused where it wrote that version into the remaining holder's row. First-party callers of these in-process reads and writes for a tool in this repo: 0 (no production source calls `get`, `getDiagnosed`, `unregister`, `getHistory`, `diff` or `rollback` with the literal type `tool`; first-party tool records in `examples/`: 0). On a booted stack no HTTP or MCP door moves from served to refused: `/meta/tool/NAME` reads through the registry's `resolveItem`, which already resolved a tool by `(package, name)` (ADR-0150 child E8), and on both HTTP transports `/meta/tool/NAME/layers` asks that plain read before it serves a layer, so a bare layered read of a two-holder name is refused `409` naming both whether or not the metadata service keeps both copies (measured with `tool` taken back out of `PACKAGE_SLOTTED_STORE_TYPES`: still `409`). The cloud's calls on the metadata service for type `tool` (a fixed-name existence probe, and AI Studio's register and retract of its built-in tool mirror, cloud reading 6837767385) are outside this count and carried to the cloud with the resolver obligation below.
Metadata-protocol: added

<!-- adr-0087: registered tool-names-per-namespace -->

**BREAKING** for five in-process answers of the metadata service for a tool (first-party count in this repo: 0), listed above under the declaration line. The fix for each:

- FROM `get('tool', NAME)` of a two-holder name TO `get('tool', NAME, PKG)`, a package the refusal names.
- FROM `get('tool', NAME, PKG)` for a package that does not hold the name TO `get('tool', NAME, HOLDER)`, the holding package (the bare read's refusal, or `list`, names it).
- FROM `unregister('tool', NAME)` of a two-holder name TO `unregister('tool', NAME, { packageId })`, or `unregisterPackage(PKG)`.
- FROM a rollback or a `DatabaseLoader.delete` by name of a two-holder name TO the same call naming the package, or removing the extra row first.
- FROM `getHistory('tool', NAME)` / `diff('tool', NAME, …)` of a name two packages saved TO the same call with `{ packageId }`, once per package.

The bare-name one-holder rule (`bare-name-one-holder.ts`) has narrowed for `tool` from one package holder per deployment to one per namespace (ADR-0150 D2, D9), as it did for `agent`, `skill`, the object-less `action`, `email_template`, `flow`, the picklist family and `webhook`. The switch, `NAMESPACE_SCOPED_BARE_NAME_TYPES`, lives in `@objectstack/core` (`package-scoped-resolution.ts`) and `@objectstack/objectql` re-exports it under the same name. A package's `tool` record has one by-name reader, `GET /meta/tool/NAME`, which already answers by `(package, name)`: it refuses `409 RESOURCE_CONFLICT` naming every served holder when two packages hold the name, and `?package=ID` and `/meta/tool/NAMESPACE/NAME` serve that package's own. A disabled package holds no name for that read (ADR-0150 D3): of two namespaces' holders, one disabled, the bare read serves the enabled one, and refuses naming both again once it is re-enabled. The install door still counts every installed holder, disabled or not. So a package no longer has to rename a tool because a package of another namespace already ships one of that name.

The metadata service stores a tool per `(package, name)` (`PACKAGE_SLOTTED_STORE_TYPES`, now `api`, `agent`, `skill`, `action`, `email_template`, `permission`, `flow`, `picklist`, `mapping`, `dataset`, `webhook` and `tool`), by the rules `package-slotted-store.ts` already applies to the others:

- The in-memory store keeps both packages' tools of one name; `list('tool')`, `loadMany('tool')` and `loadManyKeyed('tool')` answer both, where they answered the survivor. `GET /meta/tool` listed both packages' tools before this change and still does.
- `DatabaseLoader.save` of a tool: FROM one `sys_metadata` row per name with `package_id` empty TO one row per `(package, name)` with `package_id` filled; versions are numbered per name across the packages' rows, and each history row records the package whose row it snapshots. A legacy tool row the loader wrote before this release binds on its package's next save, or through `migrateDatabaseLoaderRowPackages(driver)` (`@objectstack/metadata/migrations`), which now covers `tool` rows too and can be re-run any number of times; the semantic entry `tool-names-per-namespace` registers it in the upgrade guide (ADR-0087).
- Unchanged, and now pinned for a tool on an artifact boot (`os dev`, `os start`): `GET /meta/tool/NAME/layers?package=PKG`, for a name two packages hold, serves that package's own code and effective layer. The layered read already judged the code layer at the exact address on the base, and the pin stays green with `tool` taken back out of `PACKAGE_SLOTTED_STORE_TYPES`.
- A composition whose metadata engine reads a tool through `getItem` (its registry's `readsByPackage('tool')` answers `false`) now refuses a bare `/meta/tool/NAME` read `501 NOT_IMPLEMENTED`, as it refuses every narrowed type it cannot judge. `SchemaRegistry` answers `true` for `tool` (ADR-0150 child E8), so no in-repo composition moves.

Also moved, by the same rules `package-slotted-store.ts` and the replica lane already apply to every type in `PACKAGE_SLOTTED_STORE_TYPES`:

- Replica convergence on a cluster signal for a tool: a signal naming a package FROM the first row the store returned by name TO that package's row; an unstamped signal (a writer that predates `packageId`) of a name whose active rows are bound to two or more packages FROM the first row TO a logged stop that keeps the ledger entry for the next resync (#32451).
- The boot priming pass (`@objectstack/metadata` plugin): a package-less stored tool row FROM stamped with the host's package TO left unstamped (#32812's rule for every type stored per `(package, name)`), so it is filed as its one holder's customization or the environment's own item, and under a name two packages hold as no holder's own (ADR-0150 D4).
- The protocol's in-process layered read (`getMetaItemLayered`) with a bare tool name on an artifact boot (`os dev`, `os start`), where the metadata service keeps both packages' copies: FROM the holder registered last TO `409` naming both, with the metadata service's bare read.

Not moved by this change, and carried (both already reach every narrowed type):

- The boot hydration of stored rows: two stored tool rows of one name, each naming its holder, leave only the one loaded last in the registry (the second case #32692 names for a webhook; #33127 carries it for every type stored per `(package, name)`, `tool` included). A package-less stored tool row is no longer grafted onto any holder's slot: a tool is a sealed type (no environment overlay), so since #32741 that row is declined at each holder's exact address, plain and layered, before and after the read-side hydrator, in both registration orders, and each address serves its own (pinned for a tool by name, unit and booted).
- The protocol's in-process layered read (`getMetaItemLayered`, called with a bare name and no package) runs no two-holder judgement of its own: where the metadata service gives it no copy of the tool, it falls back to the registry's by-name artifact lookup and answers the first-registered holder (#32914). Both HTTP transports refuse that read `409` before serving it (above); an in-process caller, or a host with no door chain, is not refused. Not pinned here for a boot with no service copy.

Unchanged, and still refused with the same `422 NAMESPACE_CONFLICT` envelope and sentence:

- a second package of the SAME namespace (a co-owner) holding the name: for a tool that is the namespace co-owner rule's `NamespaceNameConflictError` (child N), which judges a tool ahead of this rule at every install, boot included, exactly as before;
- a package that declares no namespace, beside any other holder of the name (a disabled one included), and any holder beside it;
- two packages of one shareable namespace (`base`, `system`, `sys`) holding the name;
- a built-in name (none for a tool: the platform requests no tool record by name).

Every covered type not yet narrowed (connector) keeps one package holder per deployment.

`os migrate stored-references` no longer lists two namespaces' holders of one tool name among the sealed clashes: they are not a clash.

Carried to the cloud: the first cloud change that reads package `tool` records (today none does: the cloud reading of 2026-11-12 on cloud `main@9b50c62e` finds the cloud's tool registry and a skill's `tools` resolving against the AI service's live registry of code-defined tools, never a `tool` record) must resolve a name from the referring item's own package (ADR-0150 D3), refuse a name two packages hold naming every candidate, and never take the first one registered; `packages/spec/liveness/tool.json` records that precondition. The cloud's three bare-name calls on the metadata service for type `tool` (cloud reading 6837767385) are carried with it: a bare removal of a name two packages hold is now refused `409`, so the built-in mirror's retract can be refused where two namespaces each ship a tool named after a built-in. That refusal fails closed and picks no holder.
10 changes: 10 additions & 0 deletions .changeset/31985-tool-names-semantic-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/spec': patch
---

docs(spec): the protocol upgrade guide names the tool's per-namespace names: an in-process read, removal or history of a tool name two packages hold, naming no package, is refused naming every holder, and a legacy loader row of a tool binds to its package (#31985)

Clause-②: no
Metadata-protocol: none

The semantic entry `tool-names-per-namespace` (`packages/spec/src/migrations/entries/semantic/18.tool-names-per-namespace.ts`) registers the narrowing `.changeset/31985-tool-names-per-namespace.md` declares (ADR-0087, `adr-0087: registered`), and the row binding it names (`migrateDatabaseLoaderRowPackages`, re-runnable): no automatic rewrite of a caller, because which holder a caller meant is a judgement about that caller. It adds no key and no export.
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@
* - C (`m31693c`) declares the picklist `m31693_hue`, which A does not depend on.
*
* A and B also each declare the agent `m31693_helper`, the skill
* `m31693_triage`, the object-less action `m31693_digest` and the email
* template `m31693_notice`: two namespaces' holders of one agent, one skill,
* one object-less action and one email template name, which the one-holder
* rule no longer calls a clash (#31701, #31697, #31699; ADR-0150 D2/D9), so
* the sealed-clash section never lists them; nor the picklist `m31693_tone`
* they both declare (#31700). The clash the seal still records is the tool
* `m31693_lookup` both declare: a tool name is held once per deployment.
* `m31693_triage`, the object-less action `m31693_digest`, the email
* template `m31693_notice` and the tool `m31693_lookup`: two namespaces'
* holders of one agent, one skill, one object-less action, one email template
* and one tool name, which the one-holder rule no longer calls a clash
* (#31701, #31697, #31699, #31985; ADR-0150 D2/D9), so the sealed-clash
* section never lists them; nor the picklist `m31693_tone` they both declare
* (#31700). The clash the seal still records is the connector `m31693_link`
* both declare: a connector name is held once per deployment.
*
* Over a database an older release left: two managed `sys_sharing_rule` rows
* naming `m31693_share` (two holders: ambiguous) and `m31693_only_a` (one
Expand Down Expand Up @@ -94,11 +95,9 @@ const rule = (name: string) => ({
accessLevel: 'read', sharedWith: { type: 'user', value: 'u1' }, active: true,
});
const list = (name: string) => ({ name, label: name, options: [{ label: 'One', value: 'one' }] });
/**
* The agent, the skill (#31701), the object-less action (#31697) and the email
* template (#31699) A and B both declare, in their own namespaces; and the
* tool, whose name is still held once per deployment: the sealed clash.
*/
/** The connector A and B both declare: a connector name is still held once per deployment, the sealed clash. */
const link = (tag: string) => ({ name: 'm31693_link', label: `${tag} link`, type: 'api', description: `${tag} link`, enabled: false });
/** The agent, the skill (#31701), the object-less action (#31697), the email template (#31699) and the tool (#31985) A and B both declare, in their own namespaces. */
const ai = (tag: string) => ({
agents: [{ name: 'm31693_helper', label: `${tag} helper`, role: `${tag} role`, instructions: `${tag} helper instructions` }],
skills: [{ name: 'm31693_triage', label: `${tag} triage`, description: `${tag} triage`, instructions: `${tag} triage instructions`, tools: [] }],
Expand All @@ -118,6 +117,7 @@ const pkgA = {
}],
sharingRules: [rule('m31693_share'), rule('m31693_only_a')],
picklists: [list('m31693_tone')],
connectors: [link('a')],
...ai('a'),
},
};
Expand All @@ -126,6 +126,7 @@ const pkgB = {
id: B, name: 'm31693b', version: '1.0.0', type: 'app', namespace: 'm31693b', dependencies: { [A]: '^1.0.0' },
sharingRules: [rule('m31693_share')],
picklists: [list('m31693_tone')],
connectors: [link('b')],
...ai('b'),
},
};
Expand Down Expand Up @@ -306,10 +307,10 @@ describe('os migrate stored-references (the report)', () => {
d8: 'R17', object: 'm31693a_task', field: 'hue', packageId: A, name: 'm31693_hue', reason: 'outside-closure',
candidates: [{ holder: 'package', packageId: C, address: 'm31693c/m31693_hue' }],
}]);
// [#31701, #31697, #31699, #31700] Only the tool: never the agent, the
// skill, the object-less action, the email template and the picklist A
// and B hold in two namespaces.
expect(payload.sealedClashes).toEqual([{ type: 'tool', name: 'm31693_lookup', candidates: [`package "${A}"`, `package "${B}"`] }]);
// [#31701, #31697, #31699, #31700, #31985] Only the connector: never the
// agent, the skill, the object-less action, the email template, the
// picklist and the tool A and B hold in two namespaces.
expect(payload.sealedClashes).toEqual([{ type: 'connector', name: 'm31693_link', candidates: [`package "${A}"`, `package "${B}"`] }]);
// The served platform carries no automation or approvals table here: named, not silently skipped.
expect(payload.tablesNotCarried).toEqual(expect.arrayContaining(['sys_automation_run', 'sys_flow_credential', 'sys_approval_request']));
}, CASE_TIMEOUT_MS);
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/package-scoped-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,9 @@ export function isBareNameOneHolderType(type: unknown): type is BareNameOneHolde
* `(package, name)`, and its doors, flow actions and flow endpoints resolve a
* name by package), then the picklist family (#31700): `picklist`,
* `mapping` and `dataset`, the `api` and the engine's `function` registry,
* then `webhook` (#31699 PR 2). A type not listed here keeps one package
* holder per deployment.
* then `webhook` (#31699 PR 2), and `tool` beside the AI family (#31985),
* whose one by-name reader (`/meta/tool/NAME`) resolves by package. A type
* not listed here keeps one package holder per deployment.
*
* Every member but {@link ENGINE_FUNCTION_TYPE} is a metadata type ADR-0150
* addresses by package ({@link PACKAGE_SCOPED_TYPES}). The function registry
Expand All @@ -155,6 +156,8 @@ export function isBareNameOneHolderType(type: unknown): type is BareNameOneHolde
export const NAMESPACE_SCOPED_BARE_NAME_TYPES: readonly BareNameOneHolderType[] = Object.freeze([
'agent',
'skill',
// the AI family's third type, in its own change (#31985)
'tool',
'action',
'email_template',
'flow',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,8 +401,9 @@ describe('#14423 item 2 — `MetadataManager.loadManyKeyed`: the plural read und
describe('[#32517] the unscoped plural reads answer every package\'s item of an unbundled by-package type', () => {
const A = 'com.acme.alpha';
const B = 'com.acme.beta';
// [#31699 PR 2] `webhook` joins the slotted set, and so these plural reads.
const UNBUNDLED = ['api', 'agent', 'skill', 'action', 'flow', 'permission', 'picklist', 'mapping', 'dataset', 'webhook'];
// [#31699 PR 2] `webhook` joins the slotted set, and so these plural reads;
// [#31985] `tool` joins with its narrowing.
const UNBUNDLED = ['api', 'agent', 'skill', 'action', 'flow', 'permission', 'picklist', 'mapping', 'dataset', 'webhook', 'tool'];
const NAME = 'shared_item';
const item = (packageId: string | undefined, extra: Record<string, unknown> = {}) => ({
name: NAME,
Expand Down
8 changes: 4 additions & 4 deletions packages/metadata/src/metadata-manager-match-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,9 +465,9 @@ describe('the in-memory api store keeps one slot per (package, name) (ADR-0150 D
* `(package, name)` for every type whose one-holder rule has narrowed to the
* namespace — `agent` and `skill` (objectql's
* `NAMESPACE_SCOPED_BARE_NAME_TYPES`, #31701; objectql pins that set a subset
* of `PACKAGE_SLOTTED_STORE_TYPES`), `flow` (#31696), and [#31700] the
* picklist family's `picklist`, `mapping` and `dataset` (`api` has its own
* describe above, the object-less `action` its own file). Two packages of two namespaces may each
* of `PACKAGE_SLOTTED_STORE_TYPES`), `flow` (#31696), [#31700] the
* picklist family's `picklist`, `mapping` and `dataset`, and `tool` (#31985)
* (`api` has its own describe above, the object-less `action` its own file). Two packages of two namespaces may each
* install such a name, and the artifact door registers every package body's
* items here through `register(…, { notify: false })`, stamped by
* `applyProtection` (`_packageId`). On main 45f0f81f the store was keyed by
Expand All @@ -476,7 +476,7 @@ describe('the in-memory api store keeps one slot per (package, name) (ADR-0150 D
* answered the survivor.
*/
describe('the in-memory store keeps one slot per (package, name) for every namespace-scoped type (ADR-0150 D4, #32151)', () => {
const NAMESPACE_SCOPED = ['agent', 'skill', 'flow', 'picklist', 'mapping', 'dataset'] as const;
const NAMESPACE_SCOPED = ['agent', 'skill', 'flow', 'picklist', 'mapping', 'dataset', 'tool'] as const;
const A = 'com.acme.alpha';
const B = 'com.acme.beta';
const shipped = (type: string, packageId: string, tag: string, envelope = false) => ({
Expand Down
Loading
Loading