Skip to content

Commit 1cd0e01

Browse files
feat(objectql, runtime): action handlers keyed by (package, objectName, name) (#31737)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi94iVCtTTWxJdCGE1yMbk32V)_ Fixes #31673 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none. No skill or doc names the handler key; `skills/objectstack-ai` describes `executeAction(object, target, ctx)` with no package, which is unchanged. ADR-0150 D4 already states the key. Narrowing: (1) D3 (a), a package caller whose only holder on the object is an environment-authored action is refused naming the environment: 0 environment-authored action items in the four first-party compositions, and 0 reachable requests (no door passes a caller's package yet). (2) The outside-closure package holder (ruled on #31527, comment 6728487836): 0 first-party callers affected. Every first-party declaration resolves for its own package (showcase 72 of 72, crm 1, todo 8, multi-package has none); the only outside-closure refusals the census found are platform packages asking for an app's action (for example `plugin-auth` asking for `showcase_mark_done`), which no first-party caller does. Also 0 reachable requests today. The no-package call to a same-object pair is unchanged (below) and goes to the maintainer on #31697. ## 维护者速读 - 改了什么:动作引擎的处理器表改为按 (包, 对象, 动作名) 存放,两个包在同一对象上注册同名动作时两份都保留;指明包的调用跑自己包的处理器,路由器对指明包的调用经 R 的解析器 (ADR-0150 D3) 解析声明。 - 为什么改:ADR-0150 D4 要求动作按包寻址;之前同对象同名的第二次注册会覆盖第一次,指明包的调用无从区分。 - 风险与回滚:不指明包的调用行为完全不变 (最后注册者运行、#24472 警告照旧);目前没有任何入口传调用方包,所以线上请求零变化。回滚即 revert 本 PR。 - 先看:`packages/objectql/src/engine-actions.ts` 的 `registerAction` / `packageScopedHandler`,以及 `packages/runtime/src/action-execution.ts` 的 `resolveRouteActionByPackage`。 - 两分钟验证:`cd packages/objectql && npx vitest run src/action-package-addressing.test.ts src/bound-action-collision-report.test.ts`,再 `cd packages/runtime && npx vitest run src/action-package-addressing.test.ts`。 - 需要你做的:#31697 决定不指明包、同对象两包同名时是否改为 409 拒绝;本 PR 只钉住现状。 ## What changes **Engine (objectql).** `ObjectQL` keeps a second handler index, keyed by package coordinate then `OBJECT:NAME` (`packageActions`). `registerAction` places each registration in its owner's coordinate (`app:<id>` reads as `<id>`, a plugin id as itself, no owner and the authored-action resync as the environment's `''`), with the same override stacking as the unqualified slot (#30065, #30542). `executeAction(objectName, actionName, ctx, { packageId })` runs that package's own handler. If the package has none, it runs the one **shared** handler under the key: host code registered with no owner (the documented way a package binds its own target-bound actions, e.g. `examples/app-todo/src/actions/register-handlers.ts`) or a plugin's. Owner-less host code sits in its own coordinate (`HOST_ACTION_COORDINATE`), apart from the environment's (`''`, where the authored-action resync registers environment-authored actions as `metadata-service`). The fallback never reaches the environment's slot or another package's (`app:<id>`) handler. Two or more shared handlers are refused with 409 `RESOURCE_CONFLICT` naming every owner (`ActionHandlerAmbiguityError`, unexported; compare `code`), never reported as "not found"; the `/actions` door answers it through its declared-status arm. `removeActionsByPackage` prunes both indexes. `registerActionOverride` binds in the base's coordinate. `listRegisteredActions({ packageId })` lists the handler a call naming that package dispatches under each key, with the coordinate whose slot holds it. **The owner gate (review 6729902179 F2).** `actionHandlerOwnerRefusal` takes the dispatch's `packageId` and judges the handler that call runs (the package-keyed listing) instead of the unqualified slot's. A declaration known to belong to another package (its `_packageId` stamp) reaches a different handler under the same key and no longer gates the call. A declaration whose package is not known, and every declaration reaching a shared handler, still gates it, as on the unqualified path, so nothing loosens. **The #31697 interim (ruling 6728550494).** The unqualified slot is untouched: a call with no `packageId` runs the last registration under `OBJECT:NAME`, and the replacement is still warned naming both owners. The code comment at that branch cites #31697. Pinned by `[#31697 interim] a call that names no package keeps today's answer exactly` (objectql) and `[#31697 interim] a caller that names no package` (runtime). **Declaration (objectql + runtime).** `resolveActionByPackage` runs R's `resolvePackageScopedName` over an owner-scoped view of the registry (`actionOwnerScopedRegistry`: the `action` holders whose owner key is the routed object, else the object-less ones). R keys `(type, name)`, so the view is what makes two packages' `approve` on two objects two names that never meet. There is no second resolver; the steps, closure and refusals are R's. A refusal on the object never falls through to an object-less holder. `resolveRouteActionDeclaration` takes an optional `callerPackageId`. Given one, it resolves through R first and reads a package holder's item back through the protocol's execution face qualified by that package (so a disabled package stays withheld, #25308). A refusal makes the `/actions` door answer 409 `RESOURCE_CONFLICT` with the candidates. Without `callerPackageId`, every rung is as before. **Lean core.** The engine imports only `action-handler-coordinate.ts`, which has no resolver import, so the `./core` entry stays clear of `@objectstack/metadata-protocol` (ADR-0076 D2; `core-boundary.ratchet.test.ts` green). The resolver-backed reads live in the runtime, per the epic seat's R2 #31716 note. No bindable or injected workaround was added. ## Acceptance notes - Single holder, bare: unchanged for the engine (`executeAction` with no package), REST (`resolveRouteActionDeclaration` with no `callerPackageId`) and nav. Phase-1 parity is pinned: one holder gives the same item R's own read gives. - Closure double holder: refused, naming both (objectql and runtime cases). - Object-bound actions are distinguished by (package, objectName, name) in both the handler index and the declaration view. - D3 (a): positive (no-package caller resolves the environment item) and negative (package caller refused naming the environment). - **ActionNavItem route.** No ActionNavItem reader exists in objectql or runtime. The Console resolves `actionDef.actionName` through `GET /meta/action/NAME` and runs it through `/actions`. The package reaches that route through G1 #31691 (`/meta` qualified address, D5 409) and G3 #31695 (the Console's half; U #31671 is closed not_planned and its carriers moved there). This PR gives both the door half: pass `callerPackageId` and the declaration and the handler are the package's. - MCP (`run_action`) is unchanged; its namespace is G2 #31692. ## Tests New: `packages/objectql/src/action-package-addressing.test.ts` (13, real `SchemaRegistry`), the `ObjectQL action handlers keyed (package, objectName, name)` block in `packages/objectql/src/bound-action-collision-report.test.ts` (12), `packages/runtime/src/action-package-addressing.test.ts` (10, real `ObjectQL` and `ObjectStackProtocolImplementation`). Forced test changes: none. Every existing pin stays green, including the four handler-face cases in `bound-action-collision-report.test.ts` and the #30065/#30542 override cases in `plugin-authored-actions.test.ts`. `scripts/engine-double-contract.pinned.json` learned the new runtime test's row-store double, which routes `findOne` through `assertEngineFindOnePredicate` (written by `check-engine-double-contract.mjs --write`). Suites on head `d65e2b13`: objectql 576 files / 11741 tests passed; runtime 567 files / 10421 passed, 19 skipped; rest 400 files / 7927 passed, 355 skipped. `tsc --noEmit` clean in objectql and runtime; eslint over both packages exit 0. Dogfood battery (narrowing arm), head `d65e2b13`: os-verify-lock VERDICT command-exit 0 · Test Files 440 passed, 3 skipped (443) · Tests 3652 passed, 41 skipped (3693) · no failures. Gates: `dispatch-gates --commands` derived 78 commands on `d65e2b13`, all exit 0 (`check:type-check-debt` run under the verify lock); `--ran` reconciliation 78 derived, 78 run, 0 NOT-MEASURED, 0 UNRUN. ### Ablations (each restored, `git diff HEAD` empty after) | Put back | Read red | |---|---| | A1: a package call falls back to any one handler under the key, including another package's | 2: `withdrawing one package keeps the other's handler…`, `never runs another package's handler` (read at d49c1e0) | | A2: R on the registry's `(type, name)` key, with no owner-scoped view | 4: `…two objects never meet…`, `falls to the object-less holders only when…`, `a refusal on the object never falls through…`, `an object-less route resolves among the object-less holders only` | | A3: a refusal on the object falls through to object-less holders | 5, including `refuses a caller whose closure holds the name on the object twice…`, both D3 (a)/outside-closure refusals, and `a refusal on the object never falls through…` | | A4: the router drops R's refusal and takes the first-match rungs | 3 (runtime): closure double holder, outside-closure package, D3 (a) environment | | B1 (epic item 1): two shared handlers answered as absence | 2: `a host handler and a plugin handler under one key are two candidates…`, `refuses 409 RESOURCE_CONFLICT naming every owner when two plugins hold the key…` | | C1 (epic item 2, F1): the resync's handlers in the host slot | 4, including `never runs the environment's handler (the authored-action resync) for another holder's declaration` | | C2 (F1, host half): owner-less host code excluded from the fallback | 2: `reaches host code registered with no owner…`, `a host handler and a plugin handler under one key are two candidates…` | | C3 (F2): the owner gate reads the unqualified listing for a package call | 1 (runtime): `a package call that runs its own handler is not gated by another package's declaration reaching the same key` | | C4 (F2): the owner gate without the `_packageId` filter | the same runtime case | ### Review notes carried - N1 (`coordinateOfUnqualified`'s identity loop never matches, so a no-`base` override names its own coordinate): accepted deviation; unreached, since the resync always passes `base`. - N2: `IObjectQLEngine.executeAction` in `packages/spec` keeps three parameters while the dispatcher calls four on `ql: any`. The card forbids a spec edit; routed to G1. - N3 (a package caller falls to the rungs when the registry view holds no item, which can serve an unhydrated environment row): accepted deviation; for G1 to pin or state. - N4 (the ADR-0110 D5 inventory and the `hasRegisteredHandler` probe still read the unqualified slot): accepted deviation; no answer changes today. ### Same-object-pair census (ruling 6728550494: stop if non-zero) I booted showcase, crm, multi-package and todo through `@objectstack/verify` on this branch and counted `OBJECT:NAME` handler keys held in two or more package coordinates, plus `action` declarations with the same (owner, name) under two or more holders. Every count was **0** (showcase: 62 handler keys, 72 declarations). The census was a scratch test and is not committed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmPWmfSh5BFJyrCajjS <!-- signed: domain:server#4 · os-steve · session_01ADHkmPWmfSh5BFJyrCajjS --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c2bb879 commit 1cd0e01

12 files changed

Lines changed: 1121 additions & 34 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
feat(objectql, runtime): the action engine keeps each package's handler under one object and action name (ADR-0150 D4)
7+
8+
Clause-②: no (narrowing)
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) nothing is retired or renamed and no stored or authored shape changes: the new package-keyed resolution is reached only by a caller that names its package, which no door does yet, so there is nothing for `objectstack migrate meta` to rewrite -->
12+
13+
Two packages can now each register a handler for the same object and action name, and both are kept. `ObjectQL#executeAction(objectName, actionName, ctx, { packageId })` runs that package's own handler. With no `packageId` it runs exactly what it ran before: the last registration under `OBJECT:NAME`, with the replacement still warned and naming both packages.
14+
15+
The router (`resolveRouteActionDeclaration`) takes an optional `callerPackageId`. Given one, it resolves the bare action name through the package-scoped resolver (ADR-0150 D3) over the `action` items that belong to the routed object: the caller's own declaration first, then the one holder in its declared closure. Two holders in the closure, or a holder outside it (another package, or an environment-authored action), is refused with every candidate named, and the `/actions` door answers 409 `RESOURCE_CONFLICT`. No door passes a caller's package yet, so every request today resolves and runs exactly as before.
16+
17+
New exports from `@objectstack/objectql`: `resolveActionByPackage`, `actionOwnerScopedRegistry`, `actionHolderCoordinate`, `actionHandlerCoordinate`, `ENVIRONMENT_ACTION_COORDINATE`, and the types `ActionPackageQuery` and `ExecuteActionOptions`.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0150 D4, #31673] The package coordinate of an action HANDLER — the
5+
* package half of the engine's `(package, objectName, name)` key, read off the
6+
* owner label a handler is registered under. Its own module, free of the
7+
* package-scoped resolver, because the engine (`engine-actions.ts`) is part of
8+
* the lean `./core` entry, which never imports `@objectstack/metadata-protocol`
9+
* (ADR-0076 D2). The declaration half is `action-package-addressing.ts`.
10+
*/
11+
12+
import { handlerOwnerPackageId } from './bare-name-one-holder.js';
13+
14+
/**
15+
* The environment's own slot (ADR-0150 D1 bucket 4): the handlers the engine
16+
* plugin's authored-action resync registers (`metadata-service`) for
17+
* environment-authored rows whose name no code package ships. A resolved
18+
* environment holder (`actionHolderCoordinate`) dispatches here.
19+
*/
20+
export const ENVIRONMENT_ACTION_COORDINATE = '';
21+
22+
/**
23+
* [#31673, review 6729902179 F1] The slot of a handler registered with NO
24+
* owner: host code calling `engine.registerAction(object, name, fn)` (the
25+
* documented pattern a code package's target-bound actions use, e.g.
26+
* `examples/app-todo`). Kept apart from {@link ENVIRONMENT_ACTION_COORDINATE}
27+
* so a package call can reach host code without ever reaching an
28+
* environment-authored action's body. Not a package id: a manifest id is
29+
* reverse-domain and never parenthesised.
30+
*/
31+
export const HOST_ACTION_COORDINATE = '(host)';
32+
33+
/** The owner label the engine plugin's authored-action resync registers under. */
34+
const AUTHORED_ACTION_OWNER = 'metadata-service';
35+
36+
/**
37+
* The package coordinate of a handler registered under `owner` (the
38+
* `packageName` argument of `ObjectQL#registerAction`):
39+
*
40+
* - `app:<packageId>` (the runtime's `bindAppArtifactHandlers`) → that package;
41+
* - `metadata-service` → {@link ENVIRONMENT_ACTION_COORDINATE}: an
42+
* environment-authored action (the resync binds no row a code package
43+
* ships, `isArtifactShippedAction`, except a declared override, which is
44+
* bound at its base's coordinate instead);
45+
* - none or `''` → {@link HOST_ACTION_COORDINATE}: host code;
46+
* - any other owner (a plugin's id) → itself, as the one-holder rule names it
47+
* ({@link handlerOwnerPackageId}).
48+
*/
49+
export function actionHandlerCoordinate(owner: string | undefined): string {
50+
if (owner === AUTHORED_ACTION_OWNER) return ENVIRONMENT_ACTION_COORDINATE;
51+
if (owner === undefined || owner === '') return HOST_ACTION_COORDINATE;
52+
return handlerOwnerPackageId(owner);
53+
}
54+
55+
/**
56+
* [#31673] Is a handler at `coordinate`, registered under `owner`, one a call
57+
* naming a package with no handler of its own may fall back to? Host code
58+
* ({@link HOST_ACTION_COORDINATE}) and a plugin's handler are; a package's
59+
* (`app:<id>`) and the environment's ({@link ENVIRONMENT_ACTION_COORDINATE})
60+
* never are: those belong to another holder's declarations.
61+
*/
62+
export function isSharedHandlerCoordinate(coordinate: string, owner: string | undefined): boolean {
63+
if (coordinate === HOST_ACTION_COORDINATE) return true;
64+
if (coordinate === ENVIRONMENT_ACTION_COORDINATE || owner === undefined) return false;
65+
return handlerOwnerPackageId(owner) === owner;
66+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The action's `(package, objectName, name)` key (ADR-0150 D4) — #31673, child
5+
* E2 of epic #31527: the declaration half, R's resolver over the `action`
6+
* items that own one object ({@link resolveActionByPackage}), and the handler
7+
* half's coordinate ({@link actionHandlerCoordinate}).
8+
*
9+
* The registry is the real `SchemaRegistry`, as R's own suite builds it:
10+
* packages through `installPackage` (their manifests are the declared closure),
11+
* items through `registerItem` under each package's composite slot.
12+
*
13+
* The negative pins (`refuses`, `never`) were each ablated once — the refused
14+
* behaviour put back, the pin read red, restored — and the readings are in the
15+
* PR body, not kept as test files.
16+
*/
17+
18+
import { describe, it, expect, beforeEach } from 'vitest';
19+
import { SchemaRegistry } from './registry.js';
20+
import { resolveActionByPackage, actionHolderCoordinate } from './action-package-addressing.js';
21+
import { actionHandlerCoordinate, ENVIRONMENT_ACTION_COORDINATE, HOST_ACTION_COORDINATE } from './action-handler-coordinate.js';
22+
import { resolvePackageScopedName, type PackageScopedResolution } from './package-scoped-resolver.js';
23+
24+
const A = 'com.acme.alpha';
25+
const B = 'com.acme.beta';
26+
const P = 'com.acme.caller';
27+
28+
const manifest = (id: string, deps: string[] = []) => ({
29+
id, name: id, version: '1.0.0', type: 'app', namespace: id.split('.').pop()!,
30+
...(deps.length > 0 ? { dependencies: Object.fromEntries(deps.map((d) => [d, '^1.0.0'])) } : {}),
31+
}) as never;
32+
33+
let registry: SchemaRegistry;
34+
const install = (id: string, deps: string[] = []) => registry.installPackage(manifest(id, deps), 'code');
35+
36+
/** A package's action `name`, bound to `objectName` (or object-less), labelled by its package. */
37+
const ship = (name: string, packageId: string, objectName?: string) =>
38+
registry.registerItem('action', {
39+
name, label: `${packageId.split('.').pop()} ${objectName ?? 'global'}`, type: 'script',
40+
body: { language: 'js', source: 'return 1;', capabilities: [] },
41+
...(objectName ? { objectName } : {}),
42+
}, 'name' as never, packageId);
43+
44+
/** An environment-authored action (no package), as `sys_metadata` hydration registers it. */
45+
const authored = (name: string, objectName?: string) =>
46+
registry.registerItem('action', {
47+
name, label: `env ${objectName ?? 'global'}`, type: 'script',
48+
body: { language: 'js', source: 'return 1;', capabilities: [] },
49+
...(objectName ? { objectName } : {}), _provenance: 'org',
50+
}, 'name' as never);
51+
52+
const label = (r: PackageScopedResolution): string | undefined =>
53+
r.status === 'resolved' ? (r.item as { label?: string }).label : undefined;
54+
55+
const resolve = (objectName: string | undefined, actionName: string, callerPackageId?: string | null) =>
56+
resolveActionByPackage(registry, { objectName, actionName, callerPackageId });
57+
58+
beforeEach(() => {
59+
registry = new SchemaRegistry();
60+
registry.logLevel = 'silent';
61+
});
62+
63+
describe('ADR-0150 D4 — an object-bound action is keyed (package, objectName, name)', () => {
64+
it('two packages\' same-named actions on two objects never meet: each object resolves its own holder', () => {
65+
install(A);
66+
install(B);
67+
ship('approve', A, 'account');
68+
ship('approve', B, 'lead');
69+
// Control: R alone, on the registry's (type, name) key, sees two holders.
70+
expect(resolvePackageScopedName(registry, { type: 'action', name: 'approve' })).toMatchObject({ status: 'refused' });
71+
expect(label(resolve('account', 'approve'))).toBe('alpha account');
72+
expect(label(resolve('lead', 'approve'))).toBe('beta lead');
73+
// A's caller asks for `lead`: A's `account` action is not a candidate; B's is outside A's closure.
74+
expect(resolve('lead', 'approve', A)).toMatchObject({
75+
status: 'refused', reason: 'outside-closure', candidates: [{ kind: 'package', packageId: B }],
76+
});
77+
expect(label(resolve('account', 'approve', A))).toBe('alpha account');
78+
});
79+
80+
it('a caller of package P resolves its own action on the object before a closure holder\'s', () => {
81+
install(A);
82+
install(P, [A]);
83+
ship('approve', A, 'account');
84+
ship('approve', P, 'account');
85+
const r = resolve('account', 'approve', P);
86+
expect(r).toMatchObject({ status: 'resolved', via: 'own', holder: { kind: 'package', packageId: P } });
87+
expect(label(r)).toBe('caller account');
88+
});
89+
90+
it('refuses a caller whose closure holds the name on the object twice, naming both holders', () => {
91+
install(A);
92+
install(B);
93+
install(P, [A, B]);
94+
ship('approve', A, 'account');
95+
ship('approve', B, 'account');
96+
const r = resolve('account', 'approve', P);
97+
expect(r).toMatchObject({ status: 'refused', reason: 'ambiguous-in-closure' });
98+
if (r.status !== 'refused') throw new Error('unreachable');
99+
expect(r.candidates).toEqual([{ kind: 'package', packageId: A }, { kind: 'package', packageId: B }]);
100+
for (const id of [A, B]) expect(r.message).toContain(id);
101+
});
102+
103+
it('D3 (a) — refuses a package caller whose only holder on the object is an environment-authored action, naming the environment', () => {
104+
install(P);
105+
authored('approve', 'account');
106+
const r = resolve('account', 'approve', P);
107+
expect(r).toMatchObject({ status: 'refused', reason: 'outside-closure', candidates: [{ kind: 'environment' }] });
108+
});
109+
110+
it('D3 (a) control — the same environment-authored action resolves for a caller with no package', () => {
111+
install(P);
112+
authored('approve', 'account');
113+
expect(resolve('account', 'approve')).toMatchObject({ status: 'resolved', via: 'environment', holder: { kind: 'environment' } });
114+
});
115+
116+
it('refuses a package caller whose only holder is a package outside its closure, naming it — never served', () => {
117+
install(A);
118+
install(P);
119+
ship('approve', A, 'account');
120+
expect(resolve('account', 'approve', P)).toMatchObject({
121+
status: 'refused', reason: 'outside-closure', candidates: [{ kind: 'package', packageId: A }],
122+
});
123+
});
124+
125+
it('falls to the object-less holders only when no holder is bound to the object, in the router\'s order', () => {
126+
install(A);
127+
install(B);
128+
install(P, [A, B]);
129+
ship('approve', A); // object-less
130+
expect(label(resolve('account', 'approve', P))).toBe('alpha global');
131+
ship('approve', B, 'account');
132+
expect(label(resolve('account', 'approve', P))).toBe('beta account');
133+
});
134+
135+
it('a refusal on the object never falls through to an object-less holder', () => {
136+
install(A);
137+
install(B);
138+
install(P, [A, B]);
139+
ship('approve', A, 'account');
140+
ship('approve', B, 'account');
141+
registry.registerItem('action', { name: 'approve', label: 'caller global', type: 'script', body: { language: 'js', source: 'return 1;', capabilities: [] } }, 'name' as never, P);
142+
expect(resolve('account', 'approve', P)).toMatchObject({ status: 'refused', reason: 'ambiguous-in-closure' });
143+
});
144+
145+
it('an object-less route resolves among the object-less holders only', () => {
146+
install(A);
147+
install(P, [A]);
148+
ship('approve', A, 'account');
149+
expect(resolve('global', 'approve', P)).toEqual({ status: 'not-found' });
150+
ship('approve', P);
151+
expect(label(resolve('global', 'approve', P))).toBe('caller global');
152+
});
153+
154+
it('one holder of the name answers the same item R\'s own read answers (phase-1 parity, reading (b))', () => {
155+
install(A);
156+
ship('approve', A, 'account');
157+
const r = resolve('account', 'approve');
158+
const direct = resolvePackageScopedName(registry, { type: 'action', name: 'approve' });
159+
expect(r).toEqual(direct);
160+
expect(r.status === 'resolved' ? actionHolderCoordinate(r) : undefined).toBe(A);
161+
});
162+
});
163+
164+
describe('ADR-0150 D4 — the coordinate a handler is registered under', () => {
165+
it('reads a package\'s handler owner `app:ID` as that package, a plugin id as itself', () => {
166+
expect(actionHandlerCoordinate(`app:${A}`)).toBe(A);
167+
expect(actionHandlerCoordinate('com.objectstack.plugin.auth')).toBe('com.objectstack.plugin.auth');
168+
});
169+
170+
it('reads the authored-action resync as the environment\'s slot, and no owner as host code\'s — two slots', () => {
171+
expect(actionHandlerCoordinate('metadata-service')).toBe(ENVIRONMENT_ACTION_COORDINATE);
172+
for (const owner of [undefined, '']) expect(actionHandlerCoordinate(owner)).toBe(HOST_ACTION_COORDINATE);
173+
expect(HOST_ACTION_COORDINATE).not.toBe(ENVIRONMENT_ACTION_COORDINATE);
174+
});
175+
176+
it('an environment holder resolves to the environment\'s coordinate', () => {
177+
authored('approve', 'account');
178+
const r = resolve('account', 'approve');
179+
expect(r.status === 'resolved' ? actionHolderCoordinate(r) : 'x').toBe(ENVIRONMENT_ACTION_COORDINATE);
180+
});
181+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* # An action is addressed by `(package, objectName, name)` (ADR-0150 D4, child E2)
5+
*
6+
* ADR-0150 D4 re-keys the action engine from `objectName:name` to
7+
* `(package, objectName, name)`, object-less actions under
8+
* `(package, global, name)`. This module holds the two halves of that key that
9+
* the engine (`engine-actions.ts`) and the router
10+
* (`@objectstack/runtime`'s `resolveRouteActionDeclaration`) share:
11+
*
12+
* - **the handler's coordinate** (`actionHandlerCoordinate`, in
13+
* `action-handler-coordinate.ts` so the lean `./core` entry the engine
14+
* belongs to never reaches the resolver's `@objectstack/metadata-protocol`
15+
* import, ADR-0076 D2): which package a registered handler belongs to;
16+
* - **the declaration's holder** ({@link resolveActionByPackage}): which
17+
* package's declaration a caller of package P means by a bare action name
18+
* on an object, resolved by the ONE rule (ADR-0150 D3,
19+
* {@link resolvePackageScopedName}) over the registry's `action` items that
20+
* own that object.
21+
*
22+
* ⛔ It does not re-implement the rule. R's resolver keys the registry's
23+
* `(type, name)`, which is the object-less action's whole identity and leaves
24+
* the object-bound key to this child (its module doc). So the object half is
25+
* a VIEW of the registry ({@link actionOwnerScopedRegistry}): the same five
26+
* reads, answering only the items whose owner key
27+
* ({@link standaloneActionOwnerKey}, the router's own test) is the routed
28+
* object. Two packages' `approve` on two objects are then two names that never
29+
* meet, and the steps, the closure and the refusals are R's.
30+
*/
31+
32+
import { GLOBAL_ACTION_OBJECT_KEY, isObjectLessActionKey, standaloneActionOwnerKey } from './action-governance.js';
33+
import { ENVIRONMENT_ACTION_COORDINATE } from './action-handler-coordinate.js';
34+
import {
35+
resolvePackageScopedName,
36+
type PackageScopedRegistry,
37+
type PackageScopedResolution,
38+
} from './package-scoped-resolver.js';
39+
40+
/**
41+
* The registry, seen through one owner key: the `action` items of each holder
42+
* whose owner key is `ownerKey` (an object's name, or the object-less
43+
* {@link GLOBAL_ACTION_OBJECT_KEY}), and nothing else. Every other type, and
44+
* the package reads, pass through unchanged.
45+
*/
46+
export function actionOwnerScopedRegistry(registry: PackageScopedRegistry, ownerKey: string): PackageScopedRegistry {
47+
const objectLess = isObjectLessActionKey(ownerKey);
48+
const owns = (item: unknown): boolean => {
49+
if (item === null || item === undefined || typeof item !== 'object') return false;
50+
const owner = standaloneActionOwnerKey(item);
51+
return objectLess ? isObjectLessActionKey(owner) : owner === ownerKey;
52+
};
53+
return {
54+
getPackage: (id) => registry.getPackage(id),
55+
getAllPackages: () => registry.getAllPackages(),
56+
packageScopedHoldersOf: (type, name) => {
57+
const holders = registry.packageScopedHoldersOf(type, name);
58+
if (type !== 'action') return holders;
59+
return holders.filter((packageId) => owns(registry.getPackageScopedItem(type, name, packageId)));
60+
},
61+
getPackageScopedItem: <T>(type: string, name: string, packageId: string, member?: string) => {
62+
const item = registry.getPackageScopedItem<T>(type, name, packageId, member);
63+
return type !== 'action' || owns(item) ? item : undefined;
64+
},
65+
getEnvironmentScopedItem: <T>(type: string, name: string, member?: string) => {
66+
const item = registry.getEnvironmentScopedItem<T>(type, name, member);
67+
return type !== 'action' || owns(item) ? item : undefined;
68+
},
69+
};
70+
}
71+
72+
/** What {@link resolveActionByPackage} is asked. */
73+
export interface ActionPackageQuery {
74+
/** The routed object, or the object-less key (`global`, legacy `*`, none). */
75+
readonly objectName: string | undefined;
76+
/** The action's declarative name. */
77+
readonly actionName: string;
78+
/** The caller's package; omitted or `null` for a caller with no package. */
79+
readonly callerPackageId?: string | null;
80+
}
81+
82+
/**
83+
* Which package's declaration `actionName` on `objectName` means to the
84+
* caller (ADR-0150 D3 over the D4 key): first among the declarations bound to
85+
* the routed object, then — when none is — among the object-less ones, the
86+
* order the router reaches a handler in (`actionHandlerObjectKeys`: the object
87+
* first, then `global`). A refusal of the first step is the answer; it never
88+
* falls through to the object-less holders.
89+
*/
90+
export function resolveActionByPackage<T = unknown>(
91+
registry: PackageScopedRegistry,
92+
query: ActionPackageQuery,
93+
): PackageScopedResolution<T> {
94+
const ask = (ownerKey: string) => resolvePackageScopedName<T>(actionOwnerScopedRegistry(registry, ownerKey), {
95+
type: 'action',
96+
name: query.actionName,
97+
callerPackageId: query.callerPackageId ?? null,
98+
});
99+
if (isObjectLessActionKey(query.objectName)) return ask(GLOBAL_ACTION_OBJECT_KEY);
100+
const bound = ask(query.objectName as string);
101+
return bound.status === 'not-found' ? ask(GLOBAL_ACTION_OBJECT_KEY) : bound;
102+
}
103+
104+
/**
105+
* The handler coordinate a resolution's holder registers under: a package
106+
* holder's package, the environment's own item the environment's slot.
107+
*/
108+
export function actionHolderCoordinate(resolution: Extract<PackageScopedResolution, { status: 'resolved' }>): string {
109+
return resolution.holder.kind === 'package' ? resolution.holder.packageId : ENVIRONMENT_ACTION_COORDINATE;
110+
}

0 commit comments

Comments
 (0)