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
14 changes: 14 additions & 0 deletions .changeset/env-action-bare-route-unheld-object.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/runtime': patch
---

fix(runtime): the bare action route serves the environment's own action declaration on an object no holder of the name binds

Clause-②: yes (widening)
Metadata-protocol: none

When two packages hold an action of one name on other objects (for example `account` and `contact`) and the environment has its own action of that name on a third object (`lead`), the bare route `/api/v1/actions/lead/NAME` answered that the action has no declaration. The route found the environment's item only through the first holder's exact address, and that address no longer serves a package-less copy (#32568).

FROM no declaration TO the environment's own action declaration, with no package, in either registration order. The route reads the environment's own item directly (its registry item and the environment's own stored row), never at a holder's exact address. An environment item with no object answers on `lead` too, and an item bound to `lead` comes before one with no object. Each holder's own route (`account`, `contact`) answers as before. A route that an enabled package's action binds answers that package's own action, as before. A route that a disabled package's action binds stays no declaration, which keeps main's answer there. So does a package-less item with no row in the environment's store (host or plugin code). A store read that fails answers 503 rather than no declaration.

This change serves the declaration. Whether a script-bodied action of that name then runs on a booted host is a separate binding question, tracked on its own.
81 changes: 77 additions & 4 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5990,6 +5990,8 @@ export async function resolveRouteActionDeclaration(deps: ActionExecutionDeps,
return routeOwnerOfRefusedRead(refusal, {
obj, actionName, ownsRoute, objectName,
readAt: (packageId) => byName.call(protocol, { type: 'action', name: actionName, packageId }),
// [#32751] …and when no holder owns it, the environment's own.
readEnvironment: () => environmentOwnerOfUnheldRoute({ ql, objectName, actionName, obj }),
});
}
return { action: undefined, obj, degraded: true, reason: err?.message ?? String(err) };
Expand Down Expand Up @@ -6061,8 +6063,10 @@ export async function resolveRouteActionDeclaration(deps: ActionExecutionDeps,
*
* The refusal stands when a candidate is not a package (the platform's or the
* environment's item has no address of its own) or two candidates own the
* route on one rung. No candidate owning the route is no declaration, as when
* the bare read served another object's action. A candidate its own address
* route on one rung. No candidate owning the route is the environment's own
* declaration when it has one there ({@link environmentOwnerOfUnheldRoute}),
* never read at a holder's address (#32751), else no declaration, as when the
* bare read served another object's action. A candidate its own address
* says it does not hold (`not-held-by-qualifier`) owns nothing, so another
* candidate that owns the route answers; with none, the bare read's refusal
* stands, since the unread candidate may own it. Any other refusal of a
Expand All @@ -6078,9 +6082,11 @@ async function routeOwnerOfRefusedRead(
objectName: string;
ownsRoute: (action: any) => boolean;
readAt: (packageId: string) => Promise<unknown>;
/** [#32751] The route's declaration when no candidate owns it ({@link environmentOwnerOfUnheldRoute}). */
readEnvironment: () => Promise<RouteActionDeclaration>;
},
): Promise<RouteActionDeclaration> {
const { obj, actionName, objectName, ownsRoute, readAt } = args;
const { obj, actionName, objectName, ownsRoute, readAt, readEnvironment } = args;
if (refusal.candidates.length < 2 || refusal.candidates.some((c) => c.kind !== 'package')) {
return { action: undefined, obj, refusal };
}
Expand Down Expand Up @@ -6117,10 +6123,77 @@ async function routeOwnerOfRefusedRead(
if (rung.length === 1) return { action: rung[0], obj };
// A candidate that could not be read may own the route: with no other
// owner, the bare read's refusal stands rather than "no declaration".
if (rung.length === 0) return unread ? { action: undefined, obj, refusal } : { action: undefined, obj };
if (rung.length === 0) return unread ? { action: undefined, obj, refusal } : readEnvironment();
return { action: undefined, obj, refusal };
}

/**
* [#32751] The declaration of a route no holder of a refused name owns
* ({@link routeOwnerOfRefusedRead}): the environment's own item, bound to the
* routed object first and object-less after (the order the router reaches a
* handler in). Never read at a holder's exact address, and never served as a
* holder's: the answer carries no package, so the dispatch is unqualified, as
* the bare call's always was.
*
* The environment's own item is a package-less registry item whose row the
* environment's store holds bound to no package ({@link environmentStoredActionNames},
* the test {@link packagelessOriginOf} applies): the row's source, never a
* byte of the definition. A package-less item with no such row is the
* platform's (host code or a plugin) and is not served here. The protocol's
* reads cannot answer this: beside two package holders its bare read refuses
* the name, and its merged listing folds the package-less item into the
* holders' slots as their stand-in.
*
* Only when no package holds the name on this route's key, a DISABLED one
* included (ruling 6824698376, letter A: a disabled package is still a
* holder). The protocol's reads withhold a disabled package's item, so they
* cannot tell; the registry's listing of every holder can
* (`includeDisabledPackages`). A route a disabled package's action binds
* stays no declaration, never handed to the environment's item, and so does
* a route on a host whose registry cannot list every holder or whose engine
* has no store to read (no `find`, or no `sys_metadata` object). Any other
* store read that throws is the plane failing to answer (ADR-0110 D3):
* `degraded`, never "no row".
*/
async function environmentOwnerOfUnheldRoute(
args: { ql: any; objectName: string; actionName: string; obj: any },
): Promise<RouteActionDeclaration> {
const { ql, objectName, actionName, obj } = args;
const none: RouteActionDeclaration = { action: undefined, obj };
const registry: any = ql?.registry;
if (typeof registry?.listItems !== 'function') return none;
let named: any[];
try {
named = (registry.listItems('action', undefined, { includeDisabledPackages: true }) ?? [])
.filter((item: any) => item?.name === actionName);
} catch {
return none;
}
const ownsRoute = (item: any): boolean => {
const owner = standaloneActionOwnerKey(item);
return owner === objectName || isObjectLessActionKey(owner);
};
const packaged = (item: any): boolean => typeof item?._packageId === 'string' && item._packageId !== '';
if (named.some((item) => packaged(item) && ownsRoute(item))) return none;
const packageless = named.filter((item) => !packaged(item) && ownsRoute(item));
if (packageless.length === 0) return none;
const store = environmentStoredActionNames(ql);
if (store === undefined) return none;
let stored: ReadonlySet<string>;
try {
stored = (await store()).actions;
} catch (err: any) {
// An engine that registers no metadata store has no environment row:
// nothing here is the environment's ({@link listingNotServed}).
if (listingNotServed(err)) return none;
return { action: undefined, obj, degraded: true, reason: err?.message ?? String(err) };
}
const own = packageless.filter((item) => stored.has(`${standaloneActionOwnerKey(item)}:${actionName}`));
const bound = own.filter((item) => !isObjectLessActionKey(standaloneActionOwnerKey(item)));
const rung = bound.length > 0 ? bound : own;
return rung.length === 1 ? { action: rung[0], obj } : none;
}

/**
* [commit e783e163d, #25120] One type's merged listing, as `GET /api/v1/meta/TYPE`
* reads it — or `undefined` when this host has no merged read to offer.
Expand Down
145 changes: 138 additions & 7 deletions packages/runtime/src/action-package-addressing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,8 +700,8 @@ describe('[#32244] the bare read of an action name two packages hold on any obje
* declaration of the holder that owns it, read at that holder's exact
* address. [#32568] A sealed type's package-less copy is declined there
* (epic ruling 6817768147), so beside two packages the environment's item is
* no route's declaration (case 2); the execution face reading the
* environment's own item is #32751.
* no holder's declaration (case 2); [#32751] a route no holder binds reads
* the environment's own item itself, never at a holder's address.
*
* BASE (main 89d815c5; #32497's head 1483899c measured the same): (1) the
* layered read answered the package's item, the in-process store the one
Expand Down Expand Up @@ -796,12 +796,143 @@ describe('[#32365] the bare action read weighs the package-less holder and serve
// `?package=alpha` served the environment's package-less item
// there (the first holder's slot, not beta's). A sealed type's
// package-less copy is declined at a holder's exact address (epic
// ruling 6817768147), so neither holder owns `lead` and the route
// answers no declaration, not a refusal. FROM the environment's
// item TO none; the execution face reading the environment's own
// item is #32751.
// ruling 6817768147), so neither holder owns `lead`. This engine
// registers no metadata store, so no row says the item is the
// environment's own: the route answers no declaration, not a
// refusal and never `degraded`. With the store, the environment's
// own item answers (#32751, below).
const onLead = await routeTo('lead');
expect([onLead.action, onLead.refusal]).toEqual([undefined, undefined]);
expect([onLead.action, onLead.refusal, onLead.degraded]).toEqual([undefined, undefined, undefined]);
});

/**
* [#32751] The route sees the object, and no holder binds `lead`: the
* environment's own item there is the route's declaration — read from
* the environment's own row and registry item, never at a holder's
* exact address (epic red line 2), and served with no package, so the
* dispatch is unqualified. The engine reads the same store the
* protocol does (the row store double), as one kernel's engine does.
*
* BASE (main a7c97297, after #32741): `lead` answered no declaration
* in both registration orders, and its handler never ran.
*/
describe('[#32751] the bare route on an object neither holder binds serves the environment\'s own item there', () => {
const engineReadsStore = () => {
(engine as any).find = async (object: string, query: any) => {
if (object !== 'sys_metadata') return [];
const where: Record<string, any> = query?.where ?? {};
return storedRows.filter((row) => Object.keys(where).every((field) => {
if (field.startsWith('$')) throw new Error(`engine store double: combinator ${field} is not implemented`);
return row[field] === where[field];
}));
};
};
const twoHoldersAndEnvironment = (envFirst: boolean, envObject: string | undefined = 'lead') => {
engine = new ObjectQL();
engine.registry.logLevel = 'silent';
(protocol as any).engine.registry = engine.registry;
engineReadsStore();
install(A);
install(B);
if (envFirst) shipPackageless('env', envObject);
ship(A, 'account');
ship(B, 'contact');
if (!envFirst) shipPackageless('env', envObject);
storeEnvironmentRow(envObject);
};

it('the environment\'s item on `lead` beside A\'s on `account` and B\'s on `contact`: `lead` serves it with no package and runs its handler, in either registration order', async () => {
for (const envFirst of [false, true]) {
twoHoldersAndEnvironment(envFirst);
engine.registerAction('lead', 'approve', () => 'ran env');
const order = `env registered ${envFirst ? 'first' : 'last'}`;
const onLead = await routeTo('lead');
expect([onLead.refusal, onLead.degraded, onLead.packageId], order).toEqual([undefined, undefined, undefined]);
expect(onLead.action, order).toMatchObject({ name: 'approve', label: 'env', objectName: 'lead' });
expect(await executeRegisteredAction(deps, engine, 'lead', ['approve'], {}, undefined), order)
.toEqual({ dispatched: true, result: 'ran env' });
}
});

it('CONTROL — each holder\'s own route is unchanged: `account` answers A\'s, `contact` B\'s, in either registration order', async () => {
for (const envFirst of [false, true]) {
twoHoldersAndEnvironment(envFirst);
const order = `env registered ${envFirst ? 'first' : 'last'}`;
const onAccount = await routeTo('account');
const onContact = await routeTo('contact');
expect([onAccount.refusal, onAccount.action?.label], order).toEqual([undefined, 'alpha']);
expect([onContact.refusal, onContact.action?.label], order).toEqual([undefined, 'beta']);
}
});

it('the environment\'s object-less item answers `lead` too, in either registration order', async () => {
for (const envFirst of [false, true]) {
twoHoldersAndEnvironment(envFirst, undefined);
const onLead = await routeTo('lead');
expect([onLead.refusal, onLead.packageId, onLead.action?.label]).toEqual([undefined, undefined, 'env']);
}
});

it('[ruling 6824698376, A] a DISABLED package binding `lead` is still a holder: `lead` stays no declaration, never the environment\'s item', async () => {
const D = 'com.acme.delta';
for (const envFirst of [false, true]) {
twoHoldersAndEnvironment(envFirst);
install(D);
ship(D, 'lead');
engine.registry.disablePackage(D);
const onLead = await routeTo('lead');
expect([onLead.action, onLead.refusal, onLead.degraded]).toEqual([undefined, undefined, undefined]);
}
});

it('NEIGHBOUR — an ENABLED third package C bound to `lead`: `lead` answers C\'s own declaration, never the environment\'s item, in either registration order', async () => {
const C = 'com.acme.gamma';
for (const envFirst of [false, true]) {
twoHoldersAndEnvironment(envFirst);
install(C);
ship(C, 'lead');
const order = `env registered ${envFirst ? 'first' : 'last'}`;
const onLead = await routeTo('lead');
expect([onLead.refusal, onLead.degraded], order).toEqual([undefined, undefined]);
expect(onLead.action, order).toMatchObject({ name: 'approve', label: 'gamma', objectName: 'lead' });
}
});

it('NEIGHBOUR — a candidate whose own address answers `not-held-by-qualifier` may own `lead`: the bare read\'s refusal stands, never the environment\'s item', async () => {
twoHoldersAndEnvironment(false);
const AB = [{ kind: 'package', packageId: A }, { kind: 'package', packageId: B }];
const refusal = (candidates: any[], reason: string) => new PackageScopedNameRefusedError({
status: 'refused', reason, type: 'action', name: 'approve', callerPackageId: null, candidates,
message: 'refused',
} as any);
const bare = refusal(AB, 'ambiguous-in-environment');
const stub: any = {
getMetaItems: async () => ({ items: [] }),
getMetaItemForExecution: async (request: { packageId?: string }) => {
if (!request.packageId) throw bare;
if (request.packageId === A) throw refusal([{ kind: 'package', packageId: A }], 'not-held-by-qualifier');
return { item: { name: 'approve', label: 'beta', type: 'script', objectName: 'contact', _packageId: B } };
},
};
const stubDeps = { ...deps, resolveService: (async (_ctx: unknown, name: string) => (name === 'protocol' ? stub : undefined)) as any };
const onLead = await resolveRouteActionDeclaration(stubDeps, { request: {} } as any, { ql: engine, objectName: 'lead', actionName: 'approve' });
expect([onLead.action, onLead.degraded]).toEqual([undefined, undefined]);
expect(onLead.refusal?.candidates).toEqual(AB);
});

it('the platform\'s item on `lead` (a package-less item with no row behind it) is not the environment\'s: `lead` stays no declaration', async () => {
twoHoldersAndEnvironment(false);
storedRows = [];
const onLead = await routeTo('lead');
expect([onLead.action, onLead.refusal, onLead.degraded]).toEqual([undefined, undefined, undefined]);
});

it('a store read that fails is the plane failing to answer: `degraded`, never no declaration (ADR-0110 D3)', async () => {
twoHoldersAndEnvironment(false);
(engine as any).find = async () => { throw new Error('store unreachable'); };
const onLead = await routeTo('lead');
expect([onLead.action, onLead.degraded, onLead.reason]).toEqual([undefined, true, 'store unreachable']);
});
});

it('all three object-less: refused naming A, B and the environment', async () => {
Expand Down
Loading