Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/objectql': patch
'@objectstack/runtime': patch
---

fix(objectql, runtime)!: a function's bare-name read is refused naming both when the platform and a package hold the name, and answers nothing for a name only a disabled package holds (ADR-0150 D4)

Clause-②: no (narrowing) — two engine function reads that name no package stop answering: `ObjectQL#resolveFunction` / `resolveFunctionEntry` refuse `409 RESOURCE_CONFLICT`, naming both, a name the platform (an owner-less registration) and a package hold, where they answered whichever registered last or, after another holder left, nothing; and they, with `resolvePackageScopedFunction` for a caller with no package (a flow `script` node of an environment-authored flow), answer nothing for a name only a disabled package holds, where they answered its function. Nothing that was refused is now answered, and no export is added.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) no key of any schema is added, removed, renamed or re-shaped and no stored row changes shape: what moves is which engine function a read that names no package answers, so there is nothing for `objectstack migrate meta` to rewrite -->

**BREAKING** for a caller of `ObjectQL#resolveFunction` / `resolveFunctionEntry` (or a flow `script` node of an environment-authored flow) naming a function that host code or a plugin registers under no package and an installed package also registers: the call is now refused, naming both. Address the function by its package (`resolvePackageScopedFunction` with the caller's package, or a packaged flow), or rename one of the two. A caller naming a function only a disabled package registers now gets no function; enable the package to get it back.

The engine keeps every holder of a function name: each package that registered one, and the platform for a registration under no package. A read that names no package resolves it by ADR-0150 D3 over those holders.

- **The platform beside a package.** The bare read refused a name two packages hold, but counted packages only, so with the platform and one package it answered the last registration: the package's function if the package registered last, the platform's if the platform did, and nothing once a third package that registered last had left. It is now refused like two packages are, `409 RESOURCE_CONFLICT` with reason `ambiguous-in-environment`, naming the package and the platform, in every registration order. The package-scoped read with no caller package already refused it; the two reads now agree.
- **A disabled package.** A disabled package is still counted as a holder, so a name it makes ambiguous stays refused, naming it (ruling 6824698376: a disable never turns a refusal into an answer). It is no longer answered: a name only a disabled package holds answers nothing to a read that names no package, and answers again once the package is enabled. A reader naming the package itself (a flow of that package) still resolves its own function, and the hook binder's bind after the seal and the cold-boot seal still count the disabled package as a holder.
- **The runtime's re-bind** (`bindAppArtifactHandlers`, when a re-bind moves a function off a package) puts back the functions that package keeps from the package's own holder entry instead of the bare read. The bare read now refuses a name the platform holds beside the package, which would have aborted the re-bind's teardown, and answers nothing for a disabled package, which would have dropped the owner spelling the package held a kept function by.

Not changed: a name one enabled package holds, or only the platform holds, answers its function as before; a name two packages hold stays refused naming both; a reader that names its package resolves as before.
50 changes: 30 additions & 20 deletions packages/objectql/src/engine-hooks-and-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
resolvePackageScopedHolders,
type PackageScopedHolderSource,
} from '@objectstack/core';
import { callerlessFunctionResolution } from './package-scoped-resolver.js';
import { hookStoppedRefusal } from './summary-errors.js';
import { startSummaryRefreshOnEnable } from './summary-enable-refresh.js';
import { HookTargetRebindError } from './hook-target-rebind-errors.js';
Expand Down Expand Up @@ -93,31 +94,39 @@ export interface HooksAndFunctionsHost {
/** The bodies of the "Class head: units, hooks, functions, body runners, hook dispatch" section of `ObjectQL`: a stateless facet, called by the class's stubs. */

/**
* [ADR-0150 D3/D4, #31700; epic seat ruling R2-C, 6812862622] The bare
* function map's entry for `name` — the last registration of the name —
* unless two PACKAGES hold it, which two namespaces may since #31700: then no
* entry is picked, and the read is refused as the rule refuses a caller with
* no package (`resolvePackageScopedHolders`, `ambiguous-in-environment`),
* naming every holder. A name one package holds, and an owner-less (platform)
* registration, answer as before. A reader that knows its package resolves
* through `functionHoldersOf` (the flow `script` node, #32073).
* [ADR-0150 D3/D4, #31700, #32907; epic seat ruling R2-C, 6812862622] The
* bare function map's entry for `name` — the last registration of the name —
* when ONE holder holds it. Two or more holders get no pick, in whatever
* order they registered: two PACKAGES (two namespaces may each hold one since
* #31700), or the PLATFORM (an owner-less registration) beside a package. The
* read is refused as the rule refuses a caller with no package
* (`resolvePackageScopedHolders`, `ambiguous-in-environment`), naming every
* holder. A disabled package is counted and never answered
* ({@link callerlessFunctionResolution}, #32933). A name one package holds,
* and one only the platform holds, answer as before. A reader that knows its
* package resolves through `functionHoldersOf` (the flow `script` node,
* #32073).
*
* @throws {PackageScopedNameRefusedError} `409 RESOURCE_CONFLICT`, every holder named.
*/
function bareFunctionEntry(
host: Pick<HooksAndFunctionsHost, 'functions' | 'functionHolders'>,
host: Pick<HooksAndFunctionsHost, '_registry' | 'functions' | 'functionHolders'>,
name: string,
): Readonly<FunctionEntry> | undefined {
const holders = host.functionHolders.get(name);
if (holders && [...holders.keys()].filter((holder) => holder !== null).length > 1) {
const refused = resolvePackageScopedHolders(
const resolution = callerlessFunctionResolution(
resolvePackageScopedHolders(
{ type: ENGINE_FUNCTION_TYPE, name, callerPackageId: null },
HooksAndFunctionsFacet.functionHoldersOf.call(host, name),
() => new Set<string>(),
);
if (refused.status === 'refused') throw new PackageScopedNameRefusedError(refused);
}
return host.functions.get(name);
),
(packageId) => host._registry.isPackageDisabled(packageId),
);
if (resolution.status === 'refused') throw new PackageScopedNameRefusedError(resolution);
// The one holder's answer is the bare map's entry, as it always was: the
// holder set keeps the same entry object for it (`registerFunction`), and
// `unregisterFunctionsByPackage` puts it back when the last registration
// leaves (#32534).
return resolution.status === 'resolved' ? host.functions.get(name) : undefined;
}

export class HooksAndFunctionsFacet {
Expand Down Expand Up @@ -303,12 +312,12 @@ export class HooksAndFunctionsFacet {
}

/** The body of `ObjectQL#resolveFunction`; the method's docblock there is its contract. */
static resolveFunction(this: Pick<HooksAndFunctionsHost, 'functions' | 'functionHolders'>, name: string): HookHandler | undefined {
static resolveFunction(this: Pick<HooksAndFunctionsHost, '_registry' | 'functions' | 'functionHolders'>, name: string): HookHandler | undefined {
return bareFunctionEntry(this, name)?.handler;
}

/** The body of `ObjectQL#resolveFunctionEntry`; the method's docblock there is its contract. */
static resolveFunctionEntry(this: Pick<HooksAndFunctionsHost, 'functions' | 'functionHolders'>, name: string): Readonly<FunctionEntry> | undefined {
static resolveFunctionEntry(this: Pick<HooksAndFunctionsHost, '_registry' | 'functions' | 'functionHolders'>, name: string): Readonly<FunctionEntry> | undefined {
return bareFunctionEntry(this, name);
}

Expand All @@ -331,8 +340,9 @@ export class HooksAndFunctionsFacet {
// [#32534] The bare entry left with the last registration while ONE
// holder of the name remains: that holder's entry is the bare answer, as
// a one-holder name's always is. Two or more remaining holders get no
// pick (the bare read of two packages' name stays refused, naming them),
// and an owner-less registration never leaves with a package.
// pick (the bare read of two holders' name stays refused, naming them,
// the platform's owner-less registration among them, #32907), and an
// owner-less registration never leaves with a package.
else if (holders.size === 1 && !this.functions.has(name)) this.functions.set(name, [...holders.values()][0]);
}
if (removed > 0) {
Expand Down
22 changes: 13 additions & 9 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,13 +761,16 @@ export class ObjectQL implements IObjectQLEngine {

/**
* Look up a registered function by name, with no package: the last
* registration of the name. [ADR-0150 D3/D4, #31700; epic seat ruling R2-C]
* A name two packages hold (two namespaces may each bind one) is refused,
* `409 RESOURCE_CONFLICT` naming every holder, never answered with one of
* them; a reader that knows its package resolves through
* {@link functionHoldersOf}.
* registration of the name. [ADR-0150 D3/D4, #31700, #32907; epic seat
* ruling R2-C] A name two holders hold — two packages (two namespaces may
* each bind one), or the platform (an owner-less registration) beside a
* package — is refused, `409 RESOURCE_CONFLICT` naming every holder, never
* answered with one of them, whichever registered last. [#32933] A disabled
* package is counted as a holder and never answered: a name only a disabled
* package holds answers nothing. A reader that knows its package resolves
* through {@link functionHoldersOf}.
*
* @throws {PackageScopedNameRefusedError} for a name two packages hold.
* @throws {PackageScopedNameRefusedError} for a name two holders hold.
*/
resolveFunction(name: string): HookHandler | undefined {
// [#29986 codemod] The body moved to `engine-hooks-and-functions.ts` (see `HooksAndFunctionsHost`).
Expand All @@ -780,9 +783,10 @@ export class ObjectQL implements IObjectQLEngine {
* it"; a caller that must also report what the call did (the automation
* engine's `script` node, feeding the #4354 run summary) needs the
* declaration, and reading it off the same registry keeps the two from
* drifting. Refused alike for a name two packages hold (above).
* drifting. Refused alike for a name two holders hold, and answers nothing
* alike for a name only a disabled package holds (above).
*
* @throws {PackageScopedNameRefusedError} for a name two packages hold.
* @throws {PackageScopedNameRefusedError} for a name two holders hold.
*/
resolveFunctionEntry(name: string): Readonly<FunctionEntry> | undefined {
// [#29986 codemod] The body moved to `engine-hooks-and-functions.ts` (see `HooksAndFunctionsHost`).
Expand All @@ -799,7 +803,7 @@ export class ObjectQL implements IObjectQLEngine {
* `script` node resolves through it (#32073), and `bindHooksToEngine` reads
* a hook's own package's entry from it. `resolveFunction` and
* `resolveFunctionEntry` answer the one last-registered entry per name, and
* refuse a name two packages hold (#31700).
* refuse a name two holders hold (#31700, #32907).
*/
functionHoldersOf(name: string): PackageScopedHolderSource<Readonly<FunctionEntry>> {
// [#31716] The body lives in `engine-hooks-and-functions.ts` (see `HooksAndFunctionsHost`).
Expand Down
52 changes: 46 additions & 6 deletions packages/objectql/src/package-scoped-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,40 @@ export interface PackageScopedFunctionQuery {
/** The engine slice the function adapter reads — every member of it, and no other. */
export type PackageScopedFunctionEngine = Pick<ObjectQL, 'registry' | 'functionHoldersOf'>;

/**
* [ADR-0150 D3, #32933; ruling A 6824698376, #32497] A function read that
* names no package — the engine's bare read (`ObjectQL#resolveFunction` /
* `resolveFunctionEntry`) and {@link resolvePackageScopedFunction} with no
* caller package — COUNTS every holder of the name and never RESOLVES TO a
* disabled one.
*
* Counted: `resolution` is the rule's answer over every holder, a disabled
* package's included, so a name a disabled package makes ambiguous stays
* refused, naming it (ruling A: a disabled package is still a holder; a
* disable never turns a refusal into an answer). Never resolved to: when the
* one holder the rule picked for a caller with no package (`unique-holder`,
* the step only such a caller reaches) is a disabled package, nothing answers
* — the served holders (#32497's default) are none. Every other answer passes
* through unchanged: a refusal, the platform's item, and a caller package's
* own item or its closure's holder (`own` / `closure`, a reader that names
* its package).
*
* Relies on the core rule (`resolvePackageScopedHolders` in
* `@objectstack/core`): a caller package stops at its steps 1–3 (`own`,
* `closure`, `platform`, or a refusal) and only the environment-wide branch
* for a caller with no package answers `unique-holder`. An edit of the rule
* that lets a caller package reach `unique-holder` must revisit this wrapper.
*/
export function callerlessFunctionResolution<T>(
resolution: PackageScopedResolution<T>,
isPackageDisabled: (packageId: string) => boolean,
): PackageScopedResolution<T> {
if (resolution.status !== 'resolved' || resolution.via !== 'unique-holder' || resolution.holder.kind !== 'package') {
return resolution;
}
return isPackageDisabled(resolution.holder.packageId) ? { status: 'not-found' } : resolution;
}

/**
* Resolve the function a flow's `script` node names, in the package of that
* flow (ADR-0150 D4's last row): the rule (`resolvePackageScopedHolders`)
Expand All @@ -194,17 +228,23 @@ export type PackageScopedFunctionEngine = Pick<ObjectQL, 'registry' | 'functionH
* reader: it names a handler key in the action registry, which E2 resolves
* by package, never a registered function. `resolveFunction` /
* `resolveFunctionEntry` answer the one last-registered function per name for
* any other caller, and refuse a name two packages hold (#31700, epic seat
* ruling R2-C).
* any other caller, and refuse a name two holders hold (#31700, epic seat
* ruling R2-C; the platform beside a package, #32907).
*
* [#32933] A caller with no package counts a disabled package as a holder and
* is never answered with its function ({@link callerlessFunctionResolution}).
*/
export function resolvePackageScopedFunction(
engine: PackageScopedFunctionEngine,
query: PackageScopedFunctionQuery,
): PackageScopedResolution<Readonly<FunctionEntry>> {
const registry = engine.registry;
return resolvePackageScopedHolders(
{ type: ENGINE_FUNCTION_TYPE, name: query.name, callerPackageId: query.callerPackageId },
engine.functionHoldersOf(query.name),
(callerPackageId) => packageResolutionClosure(registry, callerPackageId),
return callerlessFunctionResolution(
resolvePackageScopedHolders(
{ type: ENGINE_FUNCTION_TYPE, name: query.name, callerPackageId: query.callerPackageId },
engine.functionHoldersOf(query.name),
(callerPackageId) => packageResolutionClosure(registry, callerPackageId),
),
(packageId) => registry.isPackageDisabled(packageId),
);
}
Loading
Loading