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
57 changes: 57 additions & 0 deletions .changeset/converge-standalone-action-owner-key.md
Original file line numberDiff line numberDiff line change
@@ -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.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand DownExpand Up@@ -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` |
Expand Down
45 changes: 26 additions & 19 deletions packages/objectql/src/action-governance.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,25 +38,25 @@
*
* 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.
*/

/**
* The engine object key an object-LESS ("global") action registers under.
*
* 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 '<name>' 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 '<name>' on object '*' not found`.
*/
export const GLOBAL_ACTION_OBJECT_KEY = 'global';

Expand All@@ -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;
Expand Down
91 changes: 91 additions & 0 deletions packages/objectql/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -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'");
});
});
1 change: 1 addition & 0 deletions packages/objectql/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export {
isObjectLessActionKey,
actionHandlerObjectKeys,
resolveActionHandlerKeys,
standaloneActionOwnerKey,
reconcileActionRegistrations,
collectEngineActionDeclarations,
runActionGovernanceInventory,
Expand Down
35 changes: 10 additions & 25 deletions packages/objectql/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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
Expand All@@ -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)) {
Expand DownExpand Up@@ -2609,10 +2594,10 @@ export class ObjectQLPlugin implements Plugin {

const byKey = new Map<string, any>();
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(
Expand DownExpand Up@@ -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) {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
57 changes: 57 additions & 0 deletions .changeset/converge-standalone-action-owner-key.md
Original file line numberDiff line numberDiff line change
@@ -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.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand DownExpand Up@@ -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` |
Expand Down
45 changes: 26 additions & 19 deletions packages/objectql/src/action-governance.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,25 +38,25 @@
*
* 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.
*/

/**
* The engine object key an object-LESS ("global") action registers under.
*
* 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 '<name>' 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 '<name>' on object '*' not found`.
*/
export const GLOBAL_ACTION_OBJECT_KEY = 'global';

Expand All@@ -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;
Expand Down
91 changes: 91 additions & 0 deletions packages/objectql/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -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'");
});
});
1 change: 1 addition & 0 deletions packages/objectql/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export {
isObjectLessActionKey,
actionHandlerObjectKeys,
resolveActionHandlerKeys,
standaloneActionOwnerKey,
reconcileActionRegistrations,
collectEngineActionDeclarations,
runActionGovernanceInventory,
Expand Down
35 changes: 10 additions & 25 deletions packages/objectql/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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
Expand All@@ -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)) {
Expand DownExpand Up@@ -2609,10 +2594,10 @@ export class ObjectQLPlugin implements Plugin {

const byKey = new Map<string, any>();
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(
Expand DownExpand Up@@ -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) {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
57 changes: 57 additions & 0 deletions .changeset/converge-standalone-action-owner-key.md
Original file line numberDiff line numberDiff line change
@@ -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.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand DownExpand Up@@ -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` |
Expand Down
45 changes: 26 additions & 19 deletions packages/objectql/src/action-governance.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,25 +38,25 @@
*
* 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.
*/

/**
* The engine object key an object-LESS ("global") action registers under.
*
* 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 '<name>' 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 '<name>' on object '*' not found`.
*/
export const GLOBAL_ACTION_OBJECT_KEY = 'global';

Expand All@@ -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;
Expand Down
91 changes: 91 additions & 0 deletions packages/objectql/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -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'");
});
});
1 change: 1 addition & 0 deletions packages/objectql/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export {
isObjectLessActionKey,
actionHandlerObjectKeys,
resolveActionHandlerKeys,
standaloneActionOwnerKey,
reconcileActionRegistrations,
collectEngineActionDeclarations,
runActionGovernanceInventory,
Expand Down
35 changes: 10 additions & 25 deletions packages/objectql/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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
Expand All@@ -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)) {
Expand DownExpand Up@@ -2609,10 +2594,10 @@ export class ObjectQLPlugin implements Plugin {

const byKey = new Map<string, any>();
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(
Expand DownExpand Up@@ -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) {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
57 changes: 57 additions & 0 deletions .changeset/converge-standalone-action-owner-key.md
Original file line numberDiff line numberDiff line change
@@ -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.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand DownExpand Up@@ -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` |
Expand Down
45 changes: 26 additions & 19 deletions packages/objectql/src/action-governance.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,25 +38,25 @@
*
* 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.
*/

/**
* The engine object key an object-LESS ("global") action registers under.
*
* 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 '<name>' 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 '<name>' on object '*' not found`.
*/
export const GLOBAL_ACTION_OBJECT_KEY = 'global';

Expand All@@ -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;
Expand Down
91 changes: 91 additions & 0 deletions packages/objectql/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -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'");
});
});
1 change: 1 addition & 0 deletions packages/objectql/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export {
isObjectLessActionKey,
actionHandlerObjectKeys,
resolveActionHandlerKeys,
standaloneActionOwnerKey,
reconcileActionRegistrations,
collectEngineActionDeclarations,
runActionGovernanceInventory,
Expand Down
35 changes: 10 additions & 25 deletions packages/objectql/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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
Expand All@@ -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)) {
Expand DownExpand Up@@ -2609,10 +2594,10 @@ export class ObjectQLPlugin implements Plugin {

const byKey = new Map<string, any>();
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(
Expand DownExpand Up@@ -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) {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
57 changes: 57 additions & 0 deletions .changeset/converge-standalone-action-owner-key.md
Original file line numberDiff line numberDiff line change
@@ -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.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand DownExpand Up@@ -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` |
Expand Down
45 changes: 26 additions & 19 deletions packages/objectql/src/action-governance.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,25 +38,25 @@
*
* 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.
*/

/**
* The engine object key an object-LESS ("global") action registers under.
*
* 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 '<name>' 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 '<name>' on object '*' not found`.
*/
export const GLOBAL_ACTION_OBJECT_KEY = 'global';

Expand All@@ -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;
Expand Down
91 changes: 91 additions & 0 deletions packages/objectql/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -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'");
});
});
1 change: 1 addition & 0 deletions packages/objectql/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export {
isObjectLessActionKey,
actionHandlerObjectKeys,
resolveActionHandlerKeys,
standaloneActionOwnerKey,
reconcileActionRegistrations,
collectEngineActionDeclarations,
runActionGovernanceInventory,
Expand Down
35 changes: 10 additions & 25 deletions packages/objectql/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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
Expand All@@ -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)) {
Expand DownExpand Up@@ -2609,10 +2594,10 @@ export class ObjectQLPlugin implements Plugin {

const byKey = new Map<string, any>();
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(
Expand DownExpand Up@@ -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) {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
57 changes: 57 additions & 0 deletions .changeset/converge-standalone-action-owner-key.md
Original file line numberDiff line numberDiff line change
@@ -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.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand DownExpand Up@@ -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` |
Expand Down
45 changes: 26 additions & 19 deletions packages/objectql/src/action-governance.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,25 +38,25 @@
*
* 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.
*/

/**
* The engine object key an object-LESS ("global") action registers under.
*
* 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 '<name>' 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 '<name>' on object '*' not found`.
*/
export const GLOBAL_ACTION_OBJECT_KEY = 'global';

Expand All@@ -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;
Expand Down
91 changes: 91 additions & 0 deletions packages/objectql/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -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'");
});
});
1 change: 1 addition & 0 deletions packages/objectql/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export {
isObjectLessActionKey,
actionHandlerObjectKeys,
resolveActionHandlerKeys,
standaloneActionOwnerKey,
reconcileActionRegistrations,
collectEngineActionDeclarations,
runActionGovernanceInventory,
Expand Down
35 changes: 10 additions & 25 deletions packages/objectql/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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
Expand All@@ -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)) {
Expand DownExpand Up@@ -2609,10 +2594,10 @@ export class ObjectQLPlugin implements Plugin {

const byKey = new Map<string, any>();
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(
Expand DownExpand Up@@ -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) {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
57 changes: 57 additions & 0 deletions .changeset/converge-standalone-action-owner-key.md
Original file line numberDiff line numberDiff line change
@@ -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.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand DownExpand Up@@ -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` |
Expand Down
45 changes: 26 additions & 19 deletions packages/objectql/src/action-governance.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,25 +38,25 @@
*
* 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.
*/

/**
* The engine object key an object-LESS ("global") action registers under.
*
* 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 '<name>' 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 '<name>' on object '*' not found`.
*/
export const GLOBAL_ACTION_OBJECT_KEY = 'global';

Expand All@@ -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;
Expand Down
91 changes: 91 additions & 0 deletions packages/objectql/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -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'");
});
});
1 change: 1 addition & 0 deletions packages/objectql/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export {
isObjectLessActionKey,
actionHandlerObjectKeys,
resolveActionHandlerKeys,
standaloneActionOwnerKey,
reconcileActionRegistrations,
collectEngineActionDeclarations,
runActionGovernanceInventory,
Expand Down
35 changes: 10 additions & 25 deletions packages/objectql/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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
Expand All@@ -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)) {
Expand DownExpand Up@@ -2609,10 +2594,10 @@ export class ObjectQLPlugin implements Plugin {

const byKey = new Map<string, any>();
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(
Expand DownExpand Up@@ -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) {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
57 changes: 57 additions & 0 deletions .changeset/converge-standalone-action-owner-key.md
Original file line numberDiff line numberDiff line change
@@ -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.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand DownExpand Up@@ -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` |
Expand Down
45 changes: 26 additions & 19 deletions packages/objectql/src/action-governance.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,25 +38,25 @@
*
* 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.
*/

/**
* The engine object key an object-LESS ("global") action registers under.
*
* 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 '<name>' 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 '<name>' on object '*' not found`.
*/
export const GLOBAL_ACTION_OBJECT_KEY = 'global';

Expand All@@ -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;
Expand Down
91 changes: 91 additions & 0 deletions packages/objectql/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
@@ -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'");
});
});
1 change: 1 addition & 0 deletions packages/objectql/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export {
isObjectLessActionKey,
actionHandlerObjectKeys,
resolveActionHandlerKeys,
standaloneActionOwnerKey,
reconcileActionRegistrations,
collectEngineActionDeclarations,
runActionGovernanceInventory,
Expand Down
35 changes: 10 additions & 25 deletions packages/objectql/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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
Expand All@@ -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)) {
Expand DownExpand Up@@ -2609,10 +2594,10 @@ export class ObjectQLPlugin implements Plugin {

const byKey = new Map<string, any>();
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(
Expand DownExpand Up@@ -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) {
Expand Down
Loading
Loading