Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3c8f2d3
feat(objectql): resolve a bare name by package over a caller-supplied…
claude Nov 8, 2026
5e1dd2e
test(objectql): pin the holder-set entry's parity, the function holde…
claude Nov 8, 2026
3ddac4b
refactor(core, metadata-core): host the package-scoped resolution rul…
claude Nov 8, 2026
cfd7688
test(objectql): retire the federated-reader row for the moved type li…
claude Nov 8, 2026
532f3be
fix(objectql): a bundle addition holds no name for a package-scoped b…
claude Nov 8, 2026
77a49d7
chore: changeset names the bundle-addition holder change
claude Nov 9, 2026
e4cadc7
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 9, 2026
cc8a8ba
fix(core, objectql): keep a caller's own bundle addition at step 1, a…
claude Nov 9, 2026
6860f06
chore: changeset names the platform holder and the caller's own addition
claude Nov 9, 2026
83a8ccf
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 9, 2026
2bec106
fix(core, objectql): a caller carrying the compatibility API's defaul…
claude Nov 9, 2026
cde2d58
fix(metadata-core, objectql): a residual owner's declared dependencie…
claude Nov 9, 2026
15d685c
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 9, 2026
bc895c4
fix(core): a caller with no package counts the platform's item as one…
claude Nov 9, 2026
c1c1536
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 9, 2026
099edf5
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 9, 2026
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
22 changes: 22 additions & 0 deletions .changeset/31716-package-scoped-rule-lean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/core': patch
'@objectstack/metadata-core': patch
'@objectstack/lint': patch
'@objectstack/metadata-protocol': patch
'@objectstack/objectql': patch
---

feat(core, metadata-core, objectql): the package-scoped resolution rule and its closure walk move where every reader reaches them, and engine functions resolve in their referrer's package (ADR-0150 D3/D4)

Clause-②: no
Metadata-protocol: none

- **`@objectstack/core`** exports `resolvePackageScopedHolders`, the ADR-0150 D3 rule as a pure function: a caller of package P is answered P's own item, else the unique holder in P's resolution closure, else a refusal naming every candidate; a caller with no package the environment's own item, else the unique package holder, else the refusal. The holders come from a `PackageScopedHolderSource` the caller supplies (`packageScopedHolderSource` builds one from `(package, item)` pairs, a `null` package being the environment's own item), and the closure from a function the caller supplies. The refusal vocabulary moves with it: `PACKAGE_SCOPED_TYPES`, `ENGINE_FUNCTION_TYPE`, `PLATFORM_REQUESTED_BARE_NAMES_BY_TYPE`, `OVERRIDABLE_BASELINE_PACKAGES`, `BARE_NAME_ONE_HOLDER_TYPES`, `describeBareNameHolder` and the refusal and resolution types.
- **`@objectstack/metadata-core`** exports the one closure walk: `resolvePackageClosure` (moved from `@objectstack/lint`), `widenPairClosureToAppMembers` and `packageDependencyClosure` (moved from `@objectstack/metadata-protocol`), and `packageResolutionClosure` with `KERNEL_NAMESPACE` (moved from `@objectstack/objectql`): the declared dependency closure plus every `sys` package.
- **`@objectstack/lint`**, **`@objectstack/metadata-protocol`** and **`@objectstack/objectql`** re-export every moved name under the name they published, so no import changes. objectql's `resolvePackageScopedName` keeps its signature and answers, now as the rule's call over the registry's holders.
- **`@objectstack/objectql`** adds `resolvePackageScopedFunction`, the rule's call over the engine's function holders, and `ObjectQL#functionHoldersOf(name)`: every package that registered a function of that name (the owner `app:PACKAGE_ID` read as the package id), with its own entry. An owner-less registration is the platform's item (`platformItem`, holder kind `platform`, step `platform`): code a plugin or the host registers, in every caller's reach like a `sys` package, so a package caller holding none counts it beside its closure's holders and two or more refuse naming each.
- **`@objectstack/objectql`**: `SchemaRegistry#packageScopedHoldersOf` no longer counts a bundle addition as a holder (#28802). A dependent package's email template locale its base does not ship stays under the dependent, so it is no second holder of the base's name for a bare read, and a package-less resolve of the base's template answers the base. The dependent's own bare read still answers its own item (step 1, `packageScopedHoldersOf(type, name, callerPackageId)`), and a read naming the dependent (`getPackageScopedItem`) still reaches the addition. A dependent holding a member its base also holds, and a package that does not declare the holder, are still holders, and the bare read refuses them.
- **`@objectstack/core`** exports `RUNTIME_COMPATIBILITY_PACKAGE_ID` (`'__runtime__'`), the package id `ObjectQL#registerObject` stamps when its caller gives none, and reads a caller carrying it as a caller with no package (beside the `sys_metadata` sentinel), so it is not refused environment items. How an item stamped with it counts as a holder is unchanged. objectql's compatibility API takes its default from this one spelling.
- **`@objectstack/metadata-core`**: the one closure walk reads a residual owner (ADR-0130 D4, the id a multi-package stack's top-level items are owned by, which has no package record) through the dependencies it declared, via the optional `PackageClosureRegistry#residualOwnerDependenciesOf`. **`@objectstack/objectql`** records them when the manifest service registers a stack's residual (`SchemaRegistry#recordResidualOwnerDependencies` / `residualOwnerDependenciesOf`), with no package record added. A residual then reaches what it declares through every reader of the walk, and the save door's write scope for a residual owner is that declared closure, where before it narrowed nothing.

Nothing changes for a running deployment: no reader calls the rule yet, `resolveFunction` and `resolveFunctionEntry` keep answering the one last-registered function per name, the save door admits and refuses exactly what it did, and phase 1's one-holder refusal stays in force.
6 changes: 6 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export * from './artifact-packages.js';
// `@objectstack/cloud-connection`'s ledger rehydrate), and it sorts with
// `resolvePluginOrder` above.
export * from './hydration-order.js';
// [ADR-0150 D3, epic #31527 child R2] The one rule for which package's item a
// bare name means: D3's steps over a holder set and a closure the caller
// supplies, and the refusal vocabulary. Here because its readers sit inside
// objectql's lean entry, below it (the security catalog) and above it; every
// one of them already depends on this package.
export * from './package-scoped-resolution.js';
// [#22301] The two halves of resolving an app's `requires` that `os serve`
// (`@objectstack/cli`) and the verification handle (`@objectstack/verify`) both
// read — the package-owned collection reader (ADR-0130 D4) and the token →
Expand Down
206 changes: 206 additions & 0 deletions packages/core/src/package-scoped-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The package-scoped resolution rule (ADR-0150 D3) as a pure function of a
* holder set and a closure — epic #31527 child R2 (#31716). No registry: the
* holders are plain `(package, item)` pairs and the closure a plain map, so
* every arm is pinned on the rule alone. objectql's two adapters (the
* registry's holders, the engine's function holders) are pinned against this
* in `packages/objectql/src/package-scoped-resolver.test.ts`.
*
* The `red line` cases are negative pins: each was ablated once (the loosened
* behaviour put back, the pin read red, restored), readings in the PR body.
*/

import { describe, expect, it } from 'vitest';
import {
ENGINE_FUNCTION_TYPE,
OVERRIDABLE_BASELINE_PACKAGES,
RUNTIME_COMPATIBILITY_PACKAGE_ID,
packageScopedHolderSource,
resolvePackageScopedHolders,
type PackageResolutionClosureOf,
type PackageScopedResolvableType,
} from './package-scoped-resolution.js';

const A = 'com.acme.alpha';
const B = 'com.acme.beta';
const C = 'com.acme.gamma';
const P = 'com.acme.caller';
const EMAIL = [...OVERRIDABLE_BASELINE_PACKAGES.email_template][0];

/** A closure where `P` reaches `reach`, and every other caller reaches nothing. */
const closure = (reach: readonly string[]): PackageResolutionClosureOf => (caller) => new Set(caller === P ? reach : []);

const resolve = (
pairs: Array<[string | null, string]>,
reach: readonly string[],
callerPackageId?: string | null,
type: PackageScopedResolvableType = 'flow',
name = 'send_email',
) => resolvePackageScopedHolders({ type, name, callerPackageId }, packageScopedHolderSource(pairs), closure(reach));

describe('ADR-0150 D3 over a supplied holder set — a caller that belongs to a package', () => {
it("answers the caller's own item before a closure holder", () => {
expect(resolve([[A, 'a'], [P, 'p']], [A], P)).toEqual({ status: 'resolved', item: 'p', holder: { kind: 'package', packageId: P }, via: 'own' });
});

it('answers the unique holder in the closure; a holder outside it is no candidate', () => {
expect(resolve([[C, 'c'], [A, 'a']], [A], P)).toEqual({ status: 'resolved', item: 'a', holder: { kind: 'package', packageId: A }, via: 'closure' });
});

it('asks the closure only when the caller holds no item itself', () => {
let asked = 0;
const counting: PackageResolutionClosureOf = () => { asked += 1; return new Set(); };
resolvePackageScopedHolders({ type: 'flow', name: 'n', callerPackageId: P }, packageScopedHolderSource([[P, 'p']]), counting);
expect(asked).toBe(0);
resolvePackageScopedHolders({ type: 'flow', name: 'n', callerPackageId: P }, packageScopedHolderSource([[A, 'a']]), counting);
expect(asked).toBe(1);
});

it('zero holders is not-found', () => {
expect(resolve([], [A], P)).toEqual({ status: 'not-found' });
});

it('a holder that lacks what was asked answers not-found, never another holder', () => {
const source = { packageHolders: [A], packageItem: () => undefined, environmentItem: () => undefined };
expect(resolvePackageScopedHolders({ type: 'email_template', name: 'welcome', callerPackageId: P }, source, closure([A]))).toEqual({ status: 'not-found' });
});
});

describe('ADR-0150 D3 over a supplied holder set — a caller with no package', () => {
it("answers the environment's own item first, then the unique package holder", () => {
expect(resolve([[A, 'a'], [null, 'env']], [])).toMatchObject({ status: 'resolved', item: 'env', via: 'environment', holder: { kind: 'environment' } });
expect(resolve([[A, 'a']], [])).toMatchObject({ status: 'resolved', item: 'a', via: 'unique-holder' });
});

it('reads `null`, `""` and the `sys_metadata` sentinel as no caller package, and never asks the closure', () => {
const never: PackageResolutionClosureOf = () => { throw new Error('asked'); };
for (const caller of [undefined, null, '', 'sys_metadata']) {
expect(resolvePackageScopedHolders({ type: 'flow', name: 'n', callerPackageId: caller }, packageScopedHolderSource([[A, 'a']]), never))
.toMatchObject({ status: 'resolved', via: 'unique-holder' });
}
});
});

describe('red line 1 — the rule never loosens', () => {
it('two holders in the closure refuse: no first- or last-listed pick', () => {
expect(resolve([[A, 'a'], [B, 'b']], [A, B], P)).toMatchObject({ status: 'refused', reason: 'ambiguous-in-closure', callerPackageId: P });
});

it('two package holders refuse a caller with no package', () => {
expect(resolve([[A, 'a'], [B, 'b']], [])).toMatchObject({ status: 'refused', reason: 'ambiguous-in-environment', callerPackageId: null });
});

it('a name held only outside the closure is refused, never an environment-wide guess', () => {
expect(resolve([[C, 'c']], [A], P)).toMatchObject({ status: 'refused', reason: 'outside-closure', candidates: [{ kind: 'package', packageId: C }] });
});

it("a package caller is refused the environment's own item (D3 (a))", () => {
expect(resolve([[null, 'env']], [A], P)).toMatchObject({ status: 'refused', reason: 'outside-closure', candidates: [{ kind: 'environment' }] });
});
});

describe('red line 2 — every refusal names every candidate', () => {
it('in the closure, in the environment and outside the closure', () => {
const three: Array<[string | null, string]> = [[A, 'a'], [B, 'b'], [C, 'c']];
const packages = [A, B, C].map((packageId) => ({ kind: 'package', packageId }));
for (const [reach, caller] of [[[A, B, C], P], [[], undefined]] as const) {
const r = resolve(three, reach, caller);
if (r.status !== 'refused') throw new Error(`expected a refusal, got ${r.status}`);
expect(r.candidates).toEqual(packages);
for (const id of [A, B, C]) expect(r.message).toContain(`package "${id}"`);
}
const outside = resolve([...three, [null, 'env']], [], P);
if (outside.status !== 'refused') throw new Error(`expected a refusal, got ${outside.status}`);
expect(outside.candidates).toEqual([...packages, { kind: 'environment' }]);
expect(outside.message).toContain('the environment');
});
});

describe('ADR-0150 D3 exceptions over a supplied holder set', () => {
it('a platform-requested name resolves environment-wide whatever the caller', () => {
expect(resolve([[C, 'c']], [], P, 'permission', 'guest_portal')).toMatchObject({ status: 'resolved', via: 'unique-holder' });
expect(resolve([[C, 'c']], [], P, 'permission', 'sales_rep')).toMatchObject({ status: 'refused', reason: 'outside-closure' });
});

it('an overridable baseline yields to another holder of the step, and answers when it is the only one', () => {
expect(resolve([[EMAIL, 'builtin']], [EMAIL], P, 'email_template', 'auth.password_reset')).toMatchObject({ status: 'resolved', via: 'baseline' });
expect(resolve([[EMAIL, 'builtin'], [A, 'a']], [EMAIL, A], P, 'email_template', 'auth.password_reset')).toMatchObject({ status: 'resolved', item: 'a', via: 'closure' });
expect(resolve([[EMAIL, 'builtin'], [A, 'a']], [], undefined, 'email_template', 'auth.password_reset')).toMatchObject({ status: 'resolved', item: 'a', via: 'unique-holder' });
});

it('resolves the engine\'s functions, and refuses positions and objects', () => {
expect(resolve([[A, 'fn']], [A], P, ENGINE_FUNCTION_TYPE, 'score')).toMatchObject({ status: 'resolved', via: 'closure' });
for (const type of ['position', 'object']) {
expect(() => resolve([[A, 'x']], [A], P, type as PackageScopedResolvableType)).toThrow(TypeError);
}
});
});

describe('the holder source', () => {
it('keeps the pairs\' order, `null` as the environment, and is a snapshot', () => {
const pairs: Array<[string | null, string]> = [[B, 'b'], [null, 'env'], [A, 'a']];
const source = packageScopedHolderSource(pairs);
pairs.push([C, 'c']);
expect(source.packageHolders).toEqual([B, A]);
expect(source.packageItem(A)).toBe('a');
expect(source.environmentItem()).toBe('env');
});

it('refuses a holder named twice: nothing picks between its two items', () => {
expect(() => packageScopedHolderSource([[A, 1], [A, 2]])).toThrow(TypeError);
expect(() => packageScopedHolderSource([[null, 1], [null, 2]])).toThrow(TypeError);
});
});

describe('the platform\'s item — in every caller\'s reach, like `sys` (epic seat ruling 6729484996)', () => {
const withPlatform = (pairs: Array<[string, string]>, platform: string | undefined) => {
const packages = packageScopedHolderSource(pairs);
return { ...packages, platformItem: () => platform };
};
const ask = (pairs: Array<[string, string]>, platform: string | undefined, reach: readonly string[], callerPackageId?: string) =>
resolvePackageScopedHolders({ type: ENGINE_FUNCTION_TYPE, name: 'score', callerPackageId }, withPlatform(pairs, platform), closure(reach));

it('a package caller whose only reachable candidate it is resolves to it; a holder outside the closure is no candidate', () => {
expect(ask([], 'host', [], P)).toEqual({ status: 'resolved', item: 'host', holder: { kind: 'platform' }, via: 'platform' });
expect(ask([[C, 'c']], 'host', [], P)).toMatchObject({ status: 'resolved', item: 'host', via: 'platform' });
});

it('the caller\'s own item comes first', () => {
expect(ask([[P, 'p']], 'host', [], P)).toMatchObject({ status: 'resolved', item: 'p', via: 'own' });
});

it('red line 1: beside a closure holder it refuses, naming each', () => {
const r = ask([[A, 'a']], 'host', [A], P);
expect(r).toMatchObject({ status: 'refused', reason: 'ambiguous-in-closure', candidates: [{ kind: 'package', packageId: A }, { kind: 'platform' }] });
expect(r.status === 'refused' && r.message).toContain('the platform');
});

it('a caller with no package: the environment\'s own item first, then the platform counted as one more holder', () => {
expect(ask([], 'host', [])).toEqual({ status: 'resolved', item: 'host', holder: { kind: 'platform' }, via: 'platform' });
const both = ask([[A, 'a']], 'host', []);
expect(both).toMatchObject({
status: 'refused', reason: 'ambiguous-in-environment', callerPackageId: null,
candidates: [{ kind: 'package', packageId: A }, { kind: 'platform' }],
});
expect(both.status === 'refused' && both.message).toContain('the platform');
const env = { ...withPlatform([[A, 'a']], 'host'), environmentItem: () => 'env' };
expect(resolvePackageScopedHolders({ type: ENGINE_FUNCTION_TYPE, name: 'score' }, env, closure([]))).toMatchObject({ item: 'env', via: 'environment' });
expect(ask([[A, 'a']], undefined, [])).toMatchObject({ item: 'a', via: 'unique-holder' });
});
});

describe('the compatibility API\'s default package id is a caller with no package (epic seat ruling from E6)', () => {
it('a `__runtime__` caller reaches an environment-only item, as a caller with no package does', () => {
expect(RUNTIME_COMPATIBILITY_PACKAGE_ID).toBe('__runtime__');
const envOnly = packageScopedHolderSource<string>([[null, 'env']]);
const never: PackageResolutionClosureOf = () => { throw new Error('asked'); };
expect(resolvePackageScopedHolders({ type: 'flow', name: 'n', callerPackageId: RUNTIME_COMPATIBILITY_PACKAGE_ID }, envOnly, never))
.toEqual({ status: 'resolved', item: 'env', holder: { kind: 'environment' }, via: 'environment' });
});

it('only the caller side: an item stamped `__runtime__` is still a package holder', () => {
expect(resolve([['__runtime__', 'r'], [A, 'a']], [])).toMatchObject({ status: 'refused', reason: 'ambiguous-in-environment' });
});
});
Loading
Loading