diff --git a/.changeset/converge-standalone-action-owner-key.md b/.changeset/converge-standalone-action-owner-key.md new file mode 100644 index 0000000000..456c476d47 --- /dev/null +++ b/.changeset/converge-standalone-action-owner-key.md @@ -0,0 +1,57 @@ +--- +"@objectstack/objectql": minor +"@objectstack/runtime": patch +--- + +refactor(objectql,runtime): give the standalone-action owner-key ladder one spelling (#14422) + +`action.objectName` -> `action.object` -> the object-less `'global'` key decides +which engine key a standalone `action` declaration is filed under. It was +written out three times — `standaloneActionOwnerKey` in +`packages/objectql/src/action-governance.ts`, `standaloneActionObjectName` in +`packages/runtime/src/action-execution.ts`, and a private +`ObjectQLPlugin.actionObjectKey` — and the only thing holding the three equal +was a sentence in each docblock saying it must stay in lockstep with the +others. #14123 was already the bill for that shape: two readers of "where does +this declaration live" answering from different code. + +All three now resolve to the one implementation. The plugin calls +`standaloneActionOwnerKey` directly (same package, four call sites, not the one +the card estimated); the runtime re-exports it in the ADR-0110 block that +already exists in that file for exactly this purpose, alongside +`GLOBAL_ACTION_OBJECT_KEY`, `isObjectLessActionKey` and the rest. No behaviour +moves: the three ladders were measured equivalent across a twelve-row truth +table before the change. + +**The divergence this removes was real, not hypothetical.** The plugin's copy +terminated on a bare `'global'` string literal while the other two return the +shared `GLOBAL_ACTION_OBJECT_KEY` constant. The constant is `'global'` today, so +the three agreed and nothing was broken — but the plugin copy was the one that +would have parted from the others in silence the day that constant moved, and +no test in the repo would have caught it. The same literal in the plugin's +`isArtifactShippedAction` reader is converged to the constant with it. + +**`_deps`: kept, as a delegating alias — not dropped.** The engine helper is +`standaloneActionOwnerKey(action)` and the runtime's name is +`standaloneActionObjectName(_deps, action)`. `_deps` was already unused, but +dropping it would move an EXPORTED signature to save two characters at the two +in-repo call sites, both of which live in `action-execution.ts` itself. The +alias keeps its arity and its meaning, so `ownsRoute` and any out-of-repo +importer compile and behave exactly as before; its body is now +`return standaloneActionOwnerKey(action);` and nothing else. + +**Levels, and the instrument.** `@objectstack/objectql` is `minor` because +`standaloneActionOwnerKey` had to be added to its published entry +(`src/index.ts`) for the runtime to import it at all — measured in the built +`packages/objectql/dist/index.d.ts`, where the name is now both declared and +exported. `@objectstack/runtime` is `patch`: `action-execution.ts` is not +re-exported from `packages/runtime/src/index.ts` and the package publishes only +`.`, so the new re-export does not reach the published entry — measured as zero +occurrences of `standaloneActionOwnerKey`, `standaloneActionObjectName` and +`GLOBAL_ACTION_OBJECT_KEY` in the built `packages/runtime/dist/index.d.ts`, +against a positive control of 32 for `HttpDispatcher`. + +The docblocks that promised lockstep are replaced by welds that enforce it — +`action-owner-key-single-source.test.ts` in each package — because a docblock +is not a check. Each is scoped to its own package's source, so neither becomes +a cross-package test input. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 211a566390..7992131fb8 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -57,7 +57,7 @@ whether **sharing grants are materialised** — so a search for "isSystem sharin returns both, and they are unrelated decisions. A fifth, closely-spelled family — `isSystemObjectName()` / -`isSystemObject()` in `packages/runtime/src/action-execution.ts:64`, +`isSystemObject()` in `packages/runtime/src/action-execution.ts:66`, `packages/mcp/src/mcp-http-tools.ts:222` — keys on the `sys_` **name prefix**, not on any flag. @@ -156,8 +156,8 @@ The largest single consumer — **20 of the 109 sites**. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` | -| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` | +| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | +| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | | 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4716`, `:6079`, `:6327`, `:6758`, `:6951` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:276`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | diff --git a/packages/objectql/src/action-governance.ts b/packages/objectql/src/action-governance.ts index 29b6f13028..0c2d30e8c3 100644 --- a/packages/objectql/src/action-governance.ts +++ b/packages/objectql/src/action-governance.ts @@ -38,13 +38,12 @@ * * Both halves are shared now. The addressing vocabulary lives here, and so * does the ownership test that decides whether a registry item covers a route - * ({@link standaloneActionOwnerKey}, in lockstep with the runtime's - * `standaloneActionObjectName` and `ObjectQLPlugin.actionObjectKey`). The - * registry rung itself arrives as the caller-injected `lookupRegistryAction`, - * because objectql cannot import the router — the one caller that holds `ql` - * hands the rung over. The invariant this file may claim, and no more: the - * inventory reports a handler as undeclared only when EVERY source the router - * resolves through answered nothing for it. + * ({@link standaloneActionOwnerKey}). The registry rung itself arrives as the + * caller-injected `lookupRegistryAction`, because objectql cannot import the + * router — the one caller that holds `ql` hands the rung over. The invariant + * this file may claim, and no more: the inventory reports a handler as + * undeclared only when EVERY source the router resolves through answered + * nothing for it. */ /** @@ -52,11 +51,12 @@ * * Canonical since #3913, and it is `'global'` because that is what the two * writers have always written: `AppPlugin` (`action.object || 'global'`) and - * `ObjectQLPlugin.actionObjectKey`. `engine.executeAction` is an exact-string - * `Map` lookup with no wildcard semantics, so the READERS have to probe the - * same literal — before this, the REST route and the MCP bridge both rotated - * to `'*'`, which nothing ever registers, and every global action came back as - * `Action '' on object '*' not found`. + * the ObjectQL plugin (now via {@link standaloneActionOwnerKey}, which is + * why that writer no longer spells the literal itself). `engine.executeAction` + * is an exact-string `Map` lookup with no wildcard semantics, so the READERS + * have to probe the same literal — before this, the REST route and the MCP + * bridge both rotated to `'*'`, which nothing ever registers, and every global + * action came back as `Action '' on object '*' not found`. */ export const GLOBAL_ACTION_OBJECT_KEY = 'global'; @@ -73,13 +73,20 @@ export function isObjectLessActionKey(objectName: string | undefined | null): bo * * Standalone `action` metadata declares `objectName` (spec `ActionSchema`); * bundle collectors attach `object`; an object-less action owns the canonical - * `'global'` key. Three writers had this same three-line ladder — the - * runtime's `standaloneActionObjectName`, `ObjectQLPlugin.actionObjectKey`, - * and an inline copy inside {@link collectEngineActionDeclarations}. It is - * spelled once here because the router's rung-2 ownership test and this - * inventory now have to agree on it exactly; the other two stay in lockstep - * by their own docblocks (the runtime cannot import backwards, and the - * plugin's copy is a private method). + * `'global'` key. Three other writers spelled this same three-line ladder — + * the runtime's `standaloneActionObjectName`, the ObjectQL plugin's private + * `actionObjectKey`, and an inline copy inside + * {@link collectEngineActionDeclarations}. All of them resolve HERE now: the + * plugin calls this function directly (same package) and + * `@objectstack/runtime` re-exports it, keeping `standaloneActionObjectName` + * as a delegating alias for its own callers. + * + * ⛔ Do not re-inline it. What this replaced was a set of docblocks promising + * lockstep, which is documentation standing in for a check — and the plugin's + * copy had already drifted in the way only a copy can: it terminated on a bare + * `'global'` literal rather than {@link GLOBAL_ACTION_OBJECT_KEY}, equal in + * value and invisible to every test, so the day the constant moved they would + * have parted in silence. */ export function standaloneActionOwnerKey(action: any): string { if (typeof action?.objectName === 'string' && action.objectName.length > 0) return action.objectName; diff --git a/packages/objectql/src/action-owner-key-single-source.test.ts b/packages/objectql/src/action-owner-key-single-source.test.ts new file mode 100644 index 0000000000..30fb7310c0 --- /dev/null +++ b/packages/objectql/src/action-owner-key-single-source.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * This package spells the standalone-action owner-key ladder ONCE (#14422). + * + * `ObjectQLPlugin` carried a private `actionObjectKey` that repeated + * {@link standaloneActionOwnerKey}'s three rungs, and the only thing holding + * the two equal was a sentence in each docblock. It had already drifted in the + * one way a copy can drift without any test noticing: the plugin's terminal + * rung returned the bare literal `'global'` while the canonical helper returns + * `GLOBAL_ACTION_OBJECT_KEY`. Equal in value on the day it was measured, and + * silently different the first time that constant moves. + * + * `@objectstack/runtime` carries the matching weld for its own copy + * (`action-owner-key-single-source.test.ts` there). This one is scoped to this + * package's source so it stays a package-local test input. + */ + +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { describe, it, expect } from 'vitest'; +import { GLOBAL_ACTION_OBJECT_KEY, standaloneActionOwnerKey } from './action-governance.js'; + +/** Rung 1 exactly as `action-governance.ts` writes it. */ +const LADDER_RUNG_1 = "typeof action?.objectName === 'string' && action.objectName.length > 0"; +/** Rung 2, likewise. */ +const LADDER_RUNG_2 = "typeof action?.object === 'string' && action.object.length > 0"; + +/** + * This package's `src` directory, located from the test file's own path via + * vitest's runner state rather than `import.meta.url`: this package builds to + * CommonJS, where `import.meta` is a TS1470 that would bill the TEST_DEBT + * ledger for a config error saying nothing about this test. + */ +function srcDir(): string { + const testPath = expect.getState().testPath; + if (!testPath) { + throw new Error('vitest did not report a testPath — the #14422 weld cannot locate this package.'); + } + return dirname(testPath); +} + +function nonTestSources(): Array<{ file: string; text: string }> { + const dir = srcDir(); + const files = readdirSync(dir).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')); + if (files.length === 0) { + throw new Error(`No sources found under ${dir} — the #14422 weld would pass vacuously. Fix this scan.`); + } + return files.map((file) => ({ file, text: readFileSync(join(dir, file), 'utf8') })); +} + +describe('standalone-action owner key — one spelling in @objectstack/objectql (#14422)', () => { + it('writes each ladder rung in exactly one file, and that file is action-governance.ts', () => { + const sources = nonTestSources(); + // Anti-vacuity: the scan must be able to SEE the canonical spelling. + // A rung constant that matched nothing would make both counts zero and + // the assertion below green for the wrong reason. + const canonical = sources.find((s) => s.file === 'action-governance.ts'); + expect(canonical, 'action-governance.ts is missing from the scan').toBeDefined(); + expect(canonical!.text).toContain(LADDER_RUNG_1); + expect(canonical!.text).toContain(LADDER_RUNG_2); + + for (const rung of [LADDER_RUNG_1, LADDER_RUNG_2]) { + const carriers = sources.filter((s) => s.text.includes(rung)).map((s) => s.file); + expect(carriers, `ladder rung re-inlined: ${rung}`).toEqual(['action-governance.ts']); + } + }); + + it('leaves no private `actionObjectKey` behind on the plugin', () => { + const plugin = nonTestSources().find((s) => s.file === 'plugin.ts'); + expect(plugin, 'plugin.ts is missing from the scan').toBeDefined(); + expect(plugin!.text).not.toContain('actionObjectKey'); + // Positive control for the negative above: the plugin does still derive + // owner keys — it just does it through the canonical helper now. + expect(plugin!.text).toContain('standaloneActionOwnerKey('); + }); + + it('terminates the ladder on the constant, never on a bare literal', () => { + expect(standaloneActionOwnerKey({})).toBe(GLOBAL_ACTION_OBJECT_KEY); + const canonical = nonTestSources().find((s) => s.file === 'action-governance.ts')!.text; + const body = canonical.match(/export function standaloneActionOwnerKey\([^)]*\): string \{([\s\S]*?)\n\}/); + if (!body) { + throw new Error( + 'Could not locate `standaloneActionOwnerKey` in action-governance.ts. ' + + 'The #14422 weld cannot verify itself — fix this parse rather than deleting it.', + ); + } + expect(body[1]).toContain('return GLOBAL_ACTION_OBJECT_KEY;'); + expect(body[1]).not.toContain("'global'"); + }); +}); diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 9d4a354b8a..0fbebb9ab8 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -9,6 +9,7 @@ export { isObjectLessActionKey, actionHandlerObjectKeys, resolveActionHandlerKeys, + standaloneActionOwnerKey, reconcileActionRegistrations, collectEngineActionDeclarations, runActionGovernanceInventory, diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 9be1d80a28..8b271e8643 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -10,7 +10,11 @@ import { StorageNameMapping } from '@objectstack/spec/system'; import { LifecycleService } from './lifecycle/lifecycle-service.js'; import { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js'; import type { DanglingReferenceAuditOptions } from './integrity/dangling-reference-audit.js'; -import { runActionGovernanceInventory } from './action-governance.js'; +import { + GLOBAL_ACTION_OBJECT_KEY, + runActionGovernanceInventory, + standaloneActionOwnerKey, +} from './action-governance.js'; // [ADR-0126 §8] The packaged-action activation ledger's durable store. The // engine holds the projection; this plugin is what attaches the store and // hydrates it once the deployment has finished registering objects. @@ -2223,25 +2227,6 @@ export class ObjectQLPlugin implements Plugin { }); } - /** - * Resolve the engine object key an action registers under. Standalone - * `action` metadata declares `objectName` (spec `ActionSchema`); bundle - * collectors attach `object`; object-less actions register under the - * `'global'` key, matching AppPlugin's bundle registration. - * - * `'global'` is the CANONICAL object-less key (#3913) — not a wildcard. - * `executeAction` is an exact-string `Map` lookup, so every reader has to - * probe this literal; the runtime's `actionHandlerObjectKeys` does, and the - * runtime's `standaloneActionObjectName` must stay in lockstep with this - * method or the declaration the MCP surface resolves stops matching the - * handler that actually runs. - */ - private actionObjectKey(action: any): string { - if (typeof action?.objectName === 'string' && action.objectName.length > 0) return action.objectName; - if (typeof action?.object === 'string' && action.object.length > 0) return action.object; - return 'global'; - } - /** * True when an action of this name is shipped by an installed CODE * package — either as a standalone `action` artifact, or embedded in a @@ -2257,8 +2242,8 @@ export class ObjectQLPlugin implements Plugin { const registry: any = this.ql?.registry; if (!registry || typeof registry.getArtifactItem !== 'function') return false; if (registry.getArtifactItem('action', name) !== undefined) return true; - const objectKey = this.actionObjectKey(action); - if (objectKey !== 'global') { + const objectKey = standaloneActionOwnerKey(action); + if (objectKey !== GLOBAL_ACTION_OBJECT_KEY) { const artifactObject: any = registry.getArtifactItem('object', objectKey); if (Array.isArray(artifactObject?.actions) && artifactObject.actions.some((a: any) => a?.name === name)) { @@ -2609,10 +2594,10 @@ export class ObjectQLPlugin implements Plugin { const byKey = new Map(); for (const a of serviceActions ?? []) { - if (a && typeof a.name === 'string') byKey.set(`${this.actionObjectKey(a)}:${a.name}`, a); + if (a && typeof a.name === 'string') byKey.set(`${standaloneActionOwnerKey(a)}:${a.name}`, a); } for (const a of authoredActions ?? []) { - if (a && typeof a.name === 'string') byKey.set(`${this.actionObjectKey(a)}:${a.name}`, a); + if (a && typeof a.name === 'string') byKey.set(`${standaloneActionOwnerKey(a)}:${a.name}`, a); } const bindable = Array.from(byKey.values()).filter( @@ -2650,7 +2635,7 @@ export class ObjectQLPlugin implements Plugin { skippedNoHandler++; // no body (target/flow/url action) or invalid body shape continue; } - ql.registerAction(this.actionObjectKey(action), action.name, handler, 'metadata-service'); + ql.registerAction(standaloneActionOwnerKey(action), action.name, handler, 'metadata-service'); registered++; } if (typeof runner !== 'function' && bindable.length > 0) { diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 5747fcc82a..b049a0e537 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -45,6 +45,7 @@ import { isObjectLessActionKey, reconcileActionRegistrations as reconcileActionRegistrationsPure, resolveActionHandlerKeys, + standaloneActionOwnerKey, } from '@objectstack/objectql'; // [ADR-0110] The addressing vocabulary and the D5 reconciliation moved to @@ -58,6 +59,7 @@ export { actionHandlerObjectKeys, isObjectLessActionKey, resolveActionHandlerKeys, + standaloneActionOwnerKey, }; /** A `sys_`-prefixed object is a system table — off-limits to external MCP agents. */ @@ -1532,16 +1534,18 @@ export async function collectActionDeclarations(deps: ActionExecutionDeps, /** - * Owning object of a standalone `action` item — must stay in lockstep with - * the ObjectQL plugin's `actionObjectKey` (the engine registration key), so + * Owning object of a standalone `action` item: spec `objectName`, then the + * bundle collector's `object`, else the object-less `GLOBAL_ACTION_OBJECT_KEY`. + * + * A DELEGATING ALIAS, not a second spelling. The ladder itself is + * {@link standaloneActionOwnerKey}, re-exported above — one implementation, so * the declaration the MCP surface resolves is the one whose handler - * `executeAction` will find: spec `objectName`, bundle-collector `object`, - * else the `'global'` wildcard. + * `executeAction` will find. This name survives because `ownsRoute` and any + * out-of-repo importer already call it, and `_deps` stays (unused, as its + * underscore already said) so the exported signature does not move under them. */ export function standaloneActionObjectName(_deps: ActionExecutionDeps, action: any): string { - if (typeof action?.objectName === 'string' && action.objectName.length > 0) return action.objectName; - if (typeof action?.object === 'string' && action.object.length > 0) return action.object; - return GLOBAL_ACTION_OBJECT_KEY; + return standaloneActionOwnerKey(action); } diff --git a/packages/runtime/src/action-owner-key-single-source.test.ts b/packages/runtime/src/action-owner-key-single-source.test.ts new file mode 100644 index 0000000000..b4db49e92e --- /dev/null +++ b/packages/runtime/src/action-owner-key-single-source.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The standalone-action owner-key ladder has ONE spelling (#14422). + * + * `action.objectName` -> `action.object` -> the object-less + * `GLOBAL_ACTION_OBJECT_KEY` decides which engine key a standalone `action` + * declaration is filed under. It used to be written out three times — the + * canonical `standaloneActionOwnerKey` in `@objectstack/objectql`, this + * package's `standaloneActionObjectName`, and a private `actionObjectKey` on + * `ObjectQLPlugin` — and the only thing holding them equal was a sentence in + * each one's docblock saying it must stay in lockstep with the others. + * + * That is documentation standing in for a check, and it had already been paid + * for once: #14123 was two readers of "where does this declaration live" + * answering from different code. The plugin copy had also drifted in the one + * way a copy can drift invisibly — it terminated on a bare `'global'` literal + * instead of the shared constant, equal in value today and silently different + * the day the constant moves. + * + * So this file is the check. Half A pins the BEHAVIOUR (the surviving alias + * agrees with the canonical helper across the whole ladder, and its + * object-less rung is the CONSTANT, not a literal that happens to match it). + * Half B pins the STRUCTURE, because behaviour alone cannot see a re-inlined + * copy: a byte-identical second spelling passes every assertion in half A. Half + * B reads this package's own source and fails if the ladder grows a second + * body here. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; +import { GLOBAL_ACTION_OBJECT_KEY, standaloneActionOwnerKey } from '@objectstack/objectql'; +import { + standaloneActionObjectName, + standaloneActionOwnerKey as reExportedOwnerKey, + type ActionExecutionDeps, +} from './action-execution.js'; + +/** `standaloneActionObjectName` ignores its first parameter — see its docblock. */ +const NO_DEPS = undefined as unknown as ActionExecutionDeps; + +/** + * Every rung, plus the guards that decide which rung answers. The empty-string + * and wrong-type rows are the ones a re-spelling gets wrong: `objectName: ''` + * must FALL THROUGH to `object` rather than answering `''`. + */ +const LADDER_CASES: Array<{ label: string; action: any; expected: string }> = [ + { label: 'rung 1 — spec `objectName`', action: { objectName: 'todo_task' }, expected: 'todo_task' }, + { label: 'rung 1 wins over rung 2', action: { objectName: 'a', object: 'b' }, expected: 'a' }, + { label: 'rung 2 — bundle-collector `object`', action: { object: 'todo_task' }, expected: 'todo_task' }, + { label: 'empty `objectName` falls through', action: { objectName: '', object: 'b' }, expected: 'b' }, + { label: 'non-string `objectName` falls through', action: { objectName: 42, object: 'b' }, expected: 'b' }, + { label: 'rung 3 — no keys at all', action: {}, expected: GLOBAL_ACTION_OBJECT_KEY }, + { label: 'rung 3 — both empty', action: { objectName: '', object: '' }, expected: GLOBAL_ACTION_OBJECT_KEY }, + { label: 'rung 3 — undefined action', action: undefined, expected: GLOBAL_ACTION_OBJECT_KEY }, + { label: 'rung 3 — null action', action: null, expected: GLOBAL_ACTION_OBJECT_KEY }, +]; + +describe('standalone-action owner key — half A: one behaviour (#14422)', () => { + it.each(LADDER_CASES)('$label', ({ action, expected }) => { + expect(standaloneActionOwnerKey(action)).toBe(expected); + expect(standaloneActionObjectName(NO_DEPS, action)).toBe(expected); + }); + + it('re-exports the engine helper itself, not a copy of it', () => { + expect(reExportedOwnerKey).toBe(standaloneActionOwnerKey); + }); + + it('answers the object-less rung with the CONSTANT, so a moved constant moves both', () => { + // Asserting against the imported constant rather than the string + // `'global'` is the whole point: a spelling that hard-codes the literal + // agrees with this today and stops agreeing the day the constant moves. + expect(standaloneActionObjectName(NO_DEPS, {})).toBe(GLOBAL_ACTION_OBJECT_KEY); + expect(standaloneActionOwnerKey({})).toBe(GLOBAL_ACTION_OBJECT_KEY); + }); +}); + +/** + * The rung-1 test as it is actually written, in `action-governance.ts`. Half B + * searches THIS package for it; finding it here would mean the ladder had been + * re-inlined rather than delegated. + */ +const LADDER_RUNG_1 = "typeof action?.objectName === 'string' && action.objectName.length > 0"; + +function readActionExecutionSource(): string { + const here = dirname(fileURLToPath(import.meta.url)); + return readFileSync(join(here, 'action-execution.ts'), 'utf8'); +} + +describe('standalone-action owner key — half B: one spelling (#14422)', () => { + it('keeps no ladder body of its own in action-execution.ts', () => { + const src = readActionExecutionSource(); + // A parse that silently matches nothing would restore exactly the + // can-never-fail property this file replaced, so prove the anchor first. + expect(src).toContain('export function standaloneActionObjectName'); + expect(src).not.toContain(LADDER_RUNG_1); + }); + + it('resolves `standaloneActionObjectName` by delegation', () => { + const src = readActionExecutionSource(); + const body = src.match( + /export function standaloneActionObjectName\([^)]*\): string \{([\s\S]*?)\n\}/, + ); + if (!body) { + throw new Error( + 'Could not locate `standaloneActionObjectName` in action-execution.ts. ' + + 'The #14422 single-source weld cannot verify itself — fix this parse rather than deleting it.', + ); + } + expect(body[1].trim()).toBe('return standaloneActionOwnerKey(action);'); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.actions-global-key.test.ts b/packages/runtime/src/http-dispatcher.actions-global-key.test.ts index 74aca0e73b..455b06cd69 100644 --- a/packages/runtime/src/http-dispatcher.actions-global-key.test.ts +++ b/packages/runtime/src/http-dispatcher.actions-global-key.test.ts @@ -8,7 +8,8 @@ * * 1. **Registration key vs lookup key.** Both writers register an * objectName-less action under the literal `'global'` — `AppPlugin` - * (`action.object || 'global'`) and `ObjectQLPlugin.actionObjectKey`. The + * (`action.object || 'global'`) and the ObjectQL plugin (whose copy of the + * ladder was converged into `standaloneActionOwnerKey` by #14422). The * REST fallback probed `'*'`, and `engine.executeAction` is an * exact-string `Map` lookup with no wildcard semantics, so the probe could * only ever miss: `Action 'log_call' on object '*' not found`.