Skip to content

Commit dc7c226

Browse files
os-muskclaude
andauthored
refactor(objectql,runtime): give the standalone-action owner-key ladder one spelling (#14667)
* refactor(objectql,runtime): give the standalone-action owner-key ladder one spelling The `action.objectName` -> `action.object` -> object-less `'global'` ladder was written out three times — `standaloneActionOwnerKey` (objectql), the runtime's `standaloneActionObjectName`, and the private `ObjectQLPlugin.actionObjectKey` — held equal only by a sentence in each docblock. All three now resolve to the one implementation: the plugin calls it directly (same package), and the runtime re-exports it in the ADR-0110 block that already exists for that purpose, keeping `standaloneActionObjectName` as a delegating alias so its exported signature does not move. The plugin copy terminated on a bare `'global'` literal where the other two return `GLOBAL_ACTION_OBJECT_KEY`; equal today, silently different the day the constant moves. Its `isArtifactShippedAction` reader is converged with it. The lockstep docblocks are replaced by package-local welds (`action-owner-key-single-source.test.ts` in each package), because a docblock is not a check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * docs(permissions): re-anchor the three system-context census citations The two lines added to `action-execution.ts`'s import and re-export blocks shifted every anchored line below them by exactly two, so `check:check-system-context-census` went red with pure line rot: the two real `isSystem` read sites moved to :138 and :401 with no row anchoring them, while the page still pointed at :136, :399 and :64. Repaired by the gate's own `--fix`, which rewrote the three citations and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b474c38 commit dc7c226

9 files changed

Lines changed: 315 additions & 55 deletions
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
refactor(objectql,runtime): give the standalone-action owner-key ladder one spelling (#14422)
7+
8+
`action.objectName` -> `action.object` -> the object-less `'global'` key decides
9+
which engine key a standalone `action` declaration is filed under. It was
10+
written out three times — `standaloneActionOwnerKey` in
11+
`packages/objectql/src/action-governance.ts`, `standaloneActionObjectName` in
12+
`packages/runtime/src/action-execution.ts`, and a private
13+
`ObjectQLPlugin.actionObjectKey` — and the only thing holding the three equal
14+
was a sentence in each docblock saying it must stay in lockstep with the
15+
others. #14123 was already the bill for that shape: two readers of "where does
16+
this declaration live" answering from different code.
17+
18+
All three now resolve to the one implementation. The plugin calls
19+
`standaloneActionOwnerKey` directly (same package, four call sites, not the one
20+
the card estimated); the runtime re-exports it in the ADR-0110 block that
21+
already exists in that file for exactly this purpose, alongside
22+
`GLOBAL_ACTION_OBJECT_KEY`, `isObjectLessActionKey` and the rest. No behaviour
23+
moves: the three ladders were measured equivalent across a twelve-row truth
24+
table before the change.
25+
26+
**The divergence this removes was real, not hypothetical.** The plugin's copy
27+
terminated on a bare `'global'` string literal while the other two return the
28+
shared `GLOBAL_ACTION_OBJECT_KEY` constant. The constant is `'global'` today, so
29+
the three agreed and nothing was broken — but the plugin copy was the one that
30+
would have parted from the others in silence the day that constant moved, and
31+
no test in the repo would have caught it. The same literal in the plugin's
32+
`isArtifactShippedAction` reader is converged to the constant with it.
33+
34+
**`_deps`: kept, as a delegating alias — not dropped.** The engine helper is
35+
`standaloneActionOwnerKey(action)` and the runtime's name is
36+
`standaloneActionObjectName(_deps, action)`. `_deps` was already unused, but
37+
dropping it would move an EXPORTED signature to save two characters at the two
38+
in-repo call sites, both of which live in `action-execution.ts` itself. The
39+
alias keeps its arity and its meaning, so `ownsRoute` and any out-of-repo
40+
importer compile and behave exactly as before; its body is now
41+
`return standaloneActionOwnerKey(action);` and nothing else.
42+
43+
**Levels, and the instrument.** `@objectstack/objectql` is `minor` because
44+
`standaloneActionOwnerKey` had to be added to its published entry
45+
(`src/index.ts`) for the runtime to import it at all — measured in the built
46+
`packages/objectql/dist/index.d.ts`, where the name is now both declared and
47+
exported. `@objectstack/runtime` is `patch`: `action-execution.ts` is not
48+
re-exported from `packages/runtime/src/index.ts` and the package publishes only
49+
`.`, so the new re-export does not reach the published entry — measured as zero
50+
occurrences of `standaloneActionOwnerKey`, `standaloneActionObjectName` and
51+
`GLOBAL_ACTION_OBJECT_KEY` in the built `packages/runtime/dist/index.d.ts`,
52+
against a positive control of 32 for `HttpDispatcher`.
53+
54+
The docblocks that promised lockstep are replaced by welds that enforce it —
55+
`action-owner-key-single-source.test.ts` in each package — because a docblock
56+
is not a check. Each is scoped to its own package's source, so neither becomes
57+
a cross-package test input.

content/docs/permissions/system-context.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ whether **sharing grants are materialised** — so a search for "isSystem sharin
5757
returns both, and they are unrelated decisions.
5858

5959
A fifth, closely-spelled family — `isSystemObjectName()` /
60-
`isSystemObject()` in `packages/runtime/src/action-execution.ts:64`,
60+
`isSystemObject()` in `packages/runtime/src/action-execution.ts:66`,
6161
`packages/mcp/src/mcp-http-tools.ts:222` — keys on the `sys_` **name prefix**,
6262
not on any flag.
6363

@@ -156,8 +156,8 @@ The largest single consumer — **20 of the 109 sites**.
156156

157157
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
158158
|:--|:---|:---|:---|:---|
159-
| 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` |
160-
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
159+
| 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` |
160+
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161161
| 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` |
162162
| 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` |
163163
| 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` |

packages/objectql/src/action-governance.ts

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,25 +38,25 @@
3838
*
3939
* Both halves are shared now. The addressing vocabulary lives here, and so
4040
* does the ownership test that decides whether a registry item covers a route
41-
* ({@link standaloneActionOwnerKey}, in lockstep with the runtime's
42-
* `standaloneActionObjectName` and `ObjectQLPlugin.actionObjectKey`). The
43-
* registry rung itself arrives as the caller-injected `lookupRegistryAction`,
44-
* because objectql cannot import the router — the one caller that holds `ql`
45-
* hands the rung over. The invariant this file may claim, and no more: the
46-
* inventory reports a handler as undeclared only when EVERY source the router
47-
* resolves through answered nothing for it.
41+
* ({@link standaloneActionOwnerKey}). The registry rung itself arrives as the
42+
* caller-injected `lookupRegistryAction`, because objectql cannot import the
43+
* router — the one caller that holds `ql` hands the rung over. The invariant
44+
* this file may claim, and no more: the inventory reports a handler as
45+
* undeclared only when EVERY source the router resolves through answered
46+
* nothing for it.
4847
*/
4948

5049
/**
5150
* The engine object key an object-LESS ("global") action registers under.
5251
*
5352
* Canonical since #3913, and it is `'global'` because that is what the two
5453
* writers have always written: `AppPlugin` (`action.object || 'global'`) and
55-
* `ObjectQLPlugin.actionObjectKey`. `engine.executeAction` is an exact-string
56-
* `Map` lookup with no wildcard semantics, so the READERS have to probe the
57-
* same literal — before this, the REST route and the MCP bridge both rotated
58-
* to `'*'`, which nothing ever registers, and every global action came back as
59-
* `Action '<name>' on object '*' not found`.
54+
* the ObjectQL plugin (now via {@link standaloneActionOwnerKey}, which is
55+
* why that writer no longer spells the literal itself). `engine.executeAction`
56+
* is an exact-string `Map` lookup with no wildcard semantics, so the READERS
57+
* have to probe the same literal — before this, the REST route and the MCP
58+
* bridge both rotated to `'*'`, which nothing ever registers, and every global
59+
* action came back as `Action '<name>' on object '*' not found`.
6060
*/
6161
export const GLOBAL_ACTION_OBJECT_KEY = 'global';
6262

@@ -73,13 +73,20 @@ export function isObjectLessActionKey(objectName: string | undefined | null): bo
7373
*
7474
* Standalone `action` metadata declares `objectName` (spec `ActionSchema`);
7575
* bundle collectors attach `object`; an object-less action owns the canonical
76-
* `'global'` key. Three writers had this same three-line ladder — the
77-
* runtime's `standaloneActionObjectName`, `ObjectQLPlugin.actionObjectKey`,
78-
* and an inline copy inside {@link collectEngineActionDeclarations}. It is
79-
* spelled once here because the router's rung-2 ownership test and this
80-
* inventory now have to agree on it exactly; the other two stay in lockstep
81-
* by their own docblocks (the runtime cannot import backwards, and the
82-
* plugin's copy is a private method).
76+
* `'global'` key. Three other writers spelled this same three-line ladder —
77+
* the runtime's `standaloneActionObjectName`, the ObjectQL plugin's private
78+
* `actionObjectKey`, and an inline copy inside
79+
* {@link collectEngineActionDeclarations}. All of them resolve HERE now: the
80+
* plugin calls this function directly (same package) and
81+
* `@objectstack/runtime` re-exports it, keeping `standaloneActionObjectName`
82+
* as a delegating alias for its own callers.
83+
*
84+
* ⛔ Do not re-inline it. What this replaced was a set of docblocks promising
85+
* lockstep, which is documentation standing in for a check — and the plugin's
86+
* copy had already drifted in the way only a copy can: it terminated on a bare
87+
* `'global'` literal rather than {@link GLOBAL_ACTION_OBJECT_KEY}, equal in
88+
* value and invisible to every test, so the day the constant moved they would
89+
* have parted in silence.
8390
*/
8491
export function standaloneActionOwnerKey(action: any): string {
8592
if (typeof action?.objectName === 'string' && action.objectName.length > 0) return action.objectName;
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* This package spells the standalone-action owner-key ladder ONCE (#14422).
5+
*
6+
* `ObjectQLPlugin` carried a private `actionObjectKey` that repeated
7+
* {@link standaloneActionOwnerKey}'s three rungs, and the only thing holding
8+
* the two equal was a sentence in each docblock. It had already drifted in the
9+
* one way a copy can drift without any test noticing: the plugin's terminal
10+
* rung returned the bare literal `'global'` while the canonical helper returns
11+
* `GLOBAL_ACTION_OBJECT_KEY`. Equal in value on the day it was measured, and
12+
* silently different the first time that constant moves.
13+
*
14+
* `@objectstack/runtime` carries the matching weld for its own copy
15+
* (`action-owner-key-single-source.test.ts` there). This one is scoped to this
16+
* package's source so it stays a package-local test input.
17+
*/
18+
19+
import { readFileSync, readdirSync } from 'node:fs';
20+
import { dirname, join } from 'node:path';
21+
import { describe, it, expect } from 'vitest';
22+
import { GLOBAL_ACTION_OBJECT_KEY, standaloneActionOwnerKey } from './action-governance.js';
23+
24+
/** Rung 1 exactly as `action-governance.ts` writes it. */
25+
const LADDER_RUNG_1 = "typeof action?.objectName === 'string' && action.objectName.length > 0";
26+
/** Rung 2, likewise. */
27+
const LADDER_RUNG_2 = "typeof action?.object === 'string' && action.object.length > 0";
28+
29+
/**
30+
* This package's `src` directory, located from the test file's own path via
31+
* vitest's runner state rather than `import.meta.url`: this package builds to
32+
* CommonJS, where `import.meta` is a TS1470 that would bill the TEST_DEBT
33+
* ledger for a config error saying nothing about this test.
34+
*/
35+
function srcDir(): string {
36+
const testPath = expect.getState().testPath;
37+
if (!testPath) {
38+
throw new Error('vitest did not report a testPath — the #14422 weld cannot locate this package.');
39+
}
40+
return dirname(testPath);
41+
}
42+
43+
function nonTestSources(): Array<{ file: string; text: string }> {
44+
const dir = srcDir();
45+
const files = readdirSync(dir).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'));
46+
if (files.length === 0) {
47+
throw new Error(`No sources found under ${dir} — the #14422 weld would pass vacuously. Fix this scan.`);
48+
}
49+
return files.map((file) => ({ file, text: readFileSync(join(dir, file), 'utf8') }));
50+
}
51+
52+
describe('standalone-action owner key — one spelling in @objectstack/objectql (#14422)', () => {
53+
it('writes each ladder rung in exactly one file, and that file is action-governance.ts', () => {
54+
const sources = nonTestSources();
55+
// Anti-vacuity: the scan must be able to SEE the canonical spelling.
56+
// A rung constant that matched nothing would make both counts zero and
57+
// the assertion below green for the wrong reason.
58+
const canonical = sources.find((s) => s.file === 'action-governance.ts');
59+
expect(canonical, 'action-governance.ts is missing from the scan').toBeDefined();
60+
expect(canonical!.text).toContain(LADDER_RUNG_1);
61+
expect(canonical!.text).toContain(LADDER_RUNG_2);
62+
63+
for (const rung of [LADDER_RUNG_1, LADDER_RUNG_2]) {
64+
const carriers = sources.filter((s) => s.text.includes(rung)).map((s) => s.file);
65+
expect(carriers, `ladder rung re-inlined: ${rung}`).toEqual(['action-governance.ts']);
66+
}
67+
});
68+
69+
it('leaves no private `actionObjectKey` behind on the plugin', () => {
70+
const plugin = nonTestSources().find((s) => s.file === 'plugin.ts');
71+
expect(plugin, 'plugin.ts is missing from the scan').toBeDefined();
72+
expect(plugin!.text).not.toContain('actionObjectKey');
73+
// Positive control for the negative above: the plugin does still derive
74+
// owner keys — it just does it through the canonical helper now.
75+
expect(plugin!.text).toContain('standaloneActionOwnerKey(');
76+
});
77+
78+
it('terminates the ladder on the constant, never on a bare literal', () => {
79+
expect(standaloneActionOwnerKey({})).toBe(GLOBAL_ACTION_OBJECT_KEY);
80+
const canonical = nonTestSources().find((s) => s.file === 'action-governance.ts')!.text;
81+
const body = canonical.match(/export function standaloneActionOwnerKey\([^)]*\): string \{([\s\S]*?)\n\}/);
82+
if (!body) {
83+
throw new Error(
84+
'Could not locate `standaloneActionOwnerKey` in action-governance.ts. '
85+
+ 'The #14422 weld cannot verify itself — fix this parse rather than deleting it.',
86+
);
87+
}
88+
expect(body[1]).toContain('return GLOBAL_ACTION_OBJECT_KEY;');
89+
expect(body[1]).not.toContain("'global'");
90+
});
91+
});

packages/objectql/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
isObjectLessActionKey,
1010
actionHandlerObjectKeys,
1111
resolveActionHandlerKeys,
12+
standaloneActionOwnerKey,
1213
reconcileActionRegistrations,
1314
collectEngineActionDeclarations,
1415
runActionGovernanceInventory,

packages/objectql/src/plugin.ts

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import { StorageNameMapping } from '@objectstack/spec/system';
1010
import { LifecycleService } from './lifecycle/lifecycle-service.js';
1111
import { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js';
1212
import type { DanglingReferenceAuditOptions } from './integrity/dangling-reference-audit.js';
13-
import { runActionGovernanceInventory } from './action-governance.js';
13+
import {
14+
GLOBAL_ACTION_OBJECT_KEY,
15+
runActionGovernanceInventory,
16+
standaloneActionOwnerKey,
17+
} from './action-governance.js';
1418
// [ADR-0126 §8] The packaged-action activation ledger's durable store. The
1519
// engine holds the projection; this plugin is what attaches the store and
1620
// hydrates it once the deployment has finished registering objects.
@@ -2223,25 +2227,6 @@ export class ObjectQLPlugin implements Plugin {
22232227
});
22242228
}
22252229

2226-
/**
2227-
* Resolve the engine object key an action registers under. Standalone
2228-
* `action` metadata declares `objectName` (spec `ActionSchema`); bundle
2229-
* collectors attach `object`; object-less actions register under the
2230-
* `'global'` key, matching AppPlugin's bundle registration.
2231-
*
2232-
* `'global'` is the CANONICAL object-less key (#3913) — not a wildcard.
2233-
* `executeAction` is an exact-string `Map` lookup, so every reader has to
2234-
* probe this literal; the runtime's `actionHandlerObjectKeys` does, and the
2235-
* runtime's `standaloneActionObjectName` must stay in lockstep with this
2236-
* method or the declaration the MCP surface resolves stops matching the
2237-
* handler that actually runs.
2238-
*/
2239-
private actionObjectKey(action: any): string {
2240-
if (typeof action?.objectName === 'string' && action.objectName.length > 0) return action.objectName;
2241-
if (typeof action?.object === 'string' && action.object.length > 0) return action.object;
2242-
return 'global';
2243-
}
2244-
22452230
/**
22462231
* True when an action of this name is shipped by an installed CODE
22472232
* package — either as a standalone `action` artifact, or embedded in a
@@ -2257,8 +2242,8 @@ export class ObjectQLPlugin implements Plugin {
22572242
const registry: any = this.ql?.registry;
22582243
if (!registry || typeof registry.getArtifactItem !== 'function') return false;
22592244
if (registry.getArtifactItem('action', name) !== undefined) return true;
2260-
const objectKey = this.actionObjectKey(action);
2261-
if (objectKey !== 'global') {
2245+
const objectKey = standaloneActionOwnerKey(action);
2246+
if (objectKey !== GLOBAL_ACTION_OBJECT_KEY) {
22622247
const artifactObject: any = registry.getArtifactItem('object', objectKey);
22632248
if (Array.isArray(artifactObject?.actions)
22642249
&& artifactObject.actions.some((a: any) => a?.name === name)) {
@@ -2609,10 +2594,10 @@ export class ObjectQLPlugin implements Plugin {
26092594

26102595
const byKey = new Map<string, any>();
26112596
for (const a of serviceActions ?? []) {
2612-
if (a && typeof a.name === 'string') byKey.set(`${this.actionObjectKey(a)}:${a.name}`, a);
2597+
if (a && typeof a.name === 'string') byKey.set(`${standaloneActionOwnerKey(a)}:${a.name}`, a);
26132598
}
26142599
for (const a of authoredActions ?? []) {
2615-
if (a && typeof a.name === 'string') byKey.set(`${this.actionObjectKey(a)}:${a.name}`, a);
2600+
if (a && typeof a.name === 'string') byKey.set(`${standaloneActionOwnerKey(a)}:${a.name}`, a);
26162601
}
26172602

26182603
const bindable = Array.from(byKey.values()).filter(
@@ -2650,7 +2635,7 @@ export class ObjectQLPlugin implements Plugin {
26502635
skippedNoHandler++; // no body (target/flow/url action) or invalid body shape
26512636
continue;
26522637
}
2653-
ql.registerAction(this.actionObjectKey(action), action.name, handler, 'metadata-service');
2638+
ql.registerAction(standaloneActionOwnerKey(action), action.name, handler, 'metadata-service');
26542639
registered++;
26552640
}
26562641
if (typeof runner !== 'function' && bindable.length > 0) {

0 commit comments

Comments
 (0)