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
47 changes: 47 additions & 0 deletions .changeset/object-less-action-key-one-predicate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/runtime": patch
---

fix(runtime): route the flow param seeder through the single object-less predicate (#14864)

`isObjectLessActionKey` (`@objectstack/objectql`) is the canonical answer to
"is this routed object the object-less placeholder": the canonical
`GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
`dispatchFlowAction` asks it directly when it decides whether to hand the
automation service an `object` at all — and then, three lines later, handed the
same `objectName` to `seedFlowActionParams`, which answered the same question
with a second, narrower comparison of its own (`objectName !==
GLOBAL_ACTION_OBJECT_KEY`).

The two parted on exactly one input, `'*'`. A request routed at the legacy
wildcard — `POST /actions/*/<action>/<id>`, which resolves today because
`actionHandlerObjectKeys` deliberately probes `'*'` last so a handler user code
registered against it still resolves — was object-less to the automation
envelope (no `object` sent) and object-BOUND to the params bag, which seeded a
nonsense `'*Id'` key beside `recordId`. Same dispatch, two answers.

The empty-string half was never part of the divergence: the `objectName &&`
truthiness leg of the old guard already covered it, and `undefined` with it.
`'*'` was the whole of it.

**Direction.** The guard is widened onto the shared predicate rather than
`isObjectLessActionKey` being narrowed. `'*'` is *unused today*, not *dead*:
nothing first-party registers under it, but it is a deliberately-honoured
legacy read path with its own docblock, reachable through the public
`engine.registerAction(objectName, …)` surface that user code calls. Retiring
it is a compatibility decision about someone else's package, not a tidy-up this
fix is entitled to make.

**Coverage.** Both functions this touches were ablated repo-wide first rather
than grepped, because a grep scoped to the file you expect a pin in cannot see
a pin living elsewhere:

- `seedFlowActionParams` gutted → 5 tests red. It was pinned all along,
indirectly, through the REST route — but every case there routes at a real
object, so the object-LESS leg, where the two predicates actually disagreed,
was the unpinned part. Now pinned, over the whole predicate domain.
- `enforceActionParams` replaced with an unconditional `return null` → 3143
passed, 0 failed. The ADR-0104 D2 gate could stop existing with nothing in
the repo noticing. Its validator is well pinned in `@objectstack/spec`; the
runtime gate around it was not, and that gate is what keeps an AI/MCP
caller's plausible-but-wrong bag out of an action body. Now pinned.
11 changes: 10 additions & 1 deletion packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -638,7 +638,16 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps,

if (rowId != null) {
const keys = new Set<string>(['recordId']);
if (objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY) {
// [#14864] ONE predicate for "object-less", the same one
// `dispatchFlowAction` asks three lines from here before it decides
// whether to hand the automation service an `object` at all. This used
// to be a second, narrower comparison (`objectName !==
// GLOBAL_ACTION_OBJECT_KEY`), and the two parted on exactly one input:
// a route resolved at the legacy `'*'` was object-less to the envelope
// and object-BOUND here, so the bag grew a nonsense `'*Id'` alias. The
// empty-string leg was never the divergence — the `objectName &&`
// truthiness test this replaces already covered it.
if (!isObjectLessActionKey(objectName)) {
keys.add(`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`);
}
if (typeof action?.recordIdParam === 'string' && action.recordIdParam) {
Expand Down
119 changes: 119 additions & 0 deletions packages/runtime/src/action-object-less-key-agreement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* "Object-less" has ONE answer inside `action-execution.ts` (#14864).
*
* `isObjectLessActionKey` (`@objectstack/objectql`) is the canonical predicate:
* the routed object is object-less when it is the canonical
* `GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
* `dispatchFlowAction` asks it directly when it decides whether to hand the
* automation service an `object` at all — and then, on the very next line,
* hands the same `objectName` to `seedFlowActionParams`, which used to answer
* the same question with a second, narrower comparison of its own.
*
* The two parted on exactly one input, `'*'`: the automation envelope treated a
* `'*'` route as object-less and omitted `object`, while the params bag treated
* it as a real object and seeded a nonsense `'*Id'` alias key beside
* `recordId`. One dispatch, two answers, three lines apart.
*
* ## Why the pin sits HERE and not only on the route
*
* `seedFlowActionParams` was NOT unpinned — `http-dispatcher.actions-type-
* dispatch.test.ts` covers its whole seeding ladder, indirectly, through the
* REST route, without ever naming it. What that file never does is route at an
* object-LESS key: every case there is `/crm_lead/...`. So the ladder was
* pinned and the object-less leg of it was not, which is why the divergence
* survived. This file pins the leg, at the level the two predicates actually
* meet: one function, the whole `isObjectLessActionKey` domain, one bag.
*
* ## The arms, and which one is the control
*
* The `OBJECT_FUL` case is an ANTI-VACUITY CONTROL, not a pin: it asserts the
* alias key IS seeded for a real object. If a future edit makes
* `seedFlowActionParams` seed nothing at all, the negative assertions below
* would all pass for the wrong reason, and this control is what fails instead.
* ⛔ A red here is not this file's finding — read the object-less arm first.
*/

import { describe, it, expect } from 'vitest';
import { GLOBAL_ACTION_OBJECT_KEY, isObjectLessActionKey } from '@objectstack/objectql';
import { seedFlowActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `seedFlowActionParams` ignores its first parameter — see its signature. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

const ROW_ID = 'row_1';

/**
* The `<objectName>Id` camelCase alias `seedFlowActionParams` seeds for an
* object-bound route, derived the way the function derives it rather than
* hard-coded — a hard-coded copy would go stale in silence the day the
* spelling changes, which is the same failure this whole card is about.
*/
const aliasKeyFor = (objectName: string): string =>
`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`;

const seedFor = (objectName: string): Record<string, unknown> =>
seedFlowActionParams(NO_DEPS, { name: 'convert_lead', type: 'flow' }, {
objectName,
record: {},
params: {},
recordId: ROW_ID,
});

/**
* Every string spelling `isObjectLessActionKey` accepts. The table is asserted
* against the predicate itself below, so narrowing the predicate (retiring
* `'*'`, say) fails HERE with a readable message instead of quietly leaving a
* row that no longer describes anything.
*/
const OBJECT_LESS_KEYS: readonly string[] = [GLOBAL_ACTION_OBJECT_KEY, '*', ''];

/** A real object — the control's route, and the one the REST pin already uses. */
const OBJECT_FUL = 'crm_lead';

describe('object-less action key — one predicate, one answer (#14864)', () => {
it('anti-vacuity control: an object-BOUND route still seeds its alias key', () => {
// Positive control. Every negative below is a claim that a key is
// absent; without this, deleting the seeding branch outright would
// turn them all green.
expect(isObjectLessActionKey(OBJECT_FUL)).toBe(false);
const bag = seedFor(OBJECT_FUL);
expect(bag[aliasKeyFor(OBJECT_FUL)]).toBe(ROW_ID);
expect(bag.recordId).toBe(ROW_ID);
});

it('the table below describes exactly what the predicate accepts', () => {
// Guards the table, not the code: a narrowed predicate must come here
// and say so rather than leaving an inert row behind.
for (const key of OBJECT_LESS_KEYS) {
expect(isObjectLessActionKey(key), `${JSON.stringify(key)} is no longer object-less`).toBe(true);
}
});

it.each(OBJECT_LESS_KEYS.map((key) => ({ key, label: JSON.stringify(key) })))(
'seeds no object alias for the object-less key $label',
({ key }) => {
const bag = seedFor(key);
// The row id still reaches the flow — this is about the ALIAS only.
expect(bag.recordId).toBe(ROW_ID);
expect(
Object.keys(bag),
`seedFlowActionParams seeded the alias key ${JSON.stringify(aliasKeyFor(key))} for the `
+ `object-less route ${JSON.stringify(key)}. isObjectLessActionKey() calls that route `
+ `object-less and dispatchFlowAction omits \`object\` from the automation envelope for `
+ `it, so the params bag must not invent an object alias either (#14864).`,
).not.toContain(aliasKeyFor(key));
},
);

it('every object-less spelling lands the SAME bag as the canonical key', () => {
// The agreement stated as one assertion: which object-less spelling a
// caller routed at must not be observable in the flow's params.
const canonical = seedFor(GLOBAL_ACTION_OBJECT_KEY);
for (const key of OBJECT_LESS_KEYS) {
expect(seedFor(key), `routing at ${JSON.stringify(key)} produced a different params bag`)
.toEqual(canonical);
}
});
});
14 changes: 13 additions & 1 deletion packages/runtime/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -150,8 +150,20 @@ describe('standalone-action owner key — half C: no bare literal (#14678)', ()
// exactly the wrong reason — the can-never-fail property this whole
// file was written to replace. Both controls are positive assertions
// against text the converged file must carry.
//
// [#14864] The second control used to be the `seedFlowActionParams`
// comparison `objectName !== GLOBAL_ACTION_OBJECT_KEY`. That guard is
// gone — it was one of the two rival answers to "is this route
// object-less", and it now delegates to `isObjectLessActionKey` like
// its neighbours. Re-anchored rather than deleted, and deliberately
// onto a site this file's own subject does not move: the warn-once log
// key in `enforceActionParams`, which is the SECOND of the three bare
// literals #14678 converged and is untouched by the predicate work.
// ⛔ Do not re-anchor a control onto the thing the next change is most
// likely to edit — a control that moves with its subject stops being a
// control.
expect(src).toContain('GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('objectName !== GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('where.objectName ?? GLOBAL_ACTION_OBJECT_KEY');

for (const literal of BARE_LITERALS) {
expect(
Expand Down
102 changes: 102 additions & 0 deletions packages/runtime/src/action-params-enforcement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `enforceActionParams` — the ADR-0104 D2 gate itself, not its validator
* (#14864).
*
* ## What was measured, and why this file exists
*
* The card that produced this file claimed neither `seedFlowActionParams` nor
* `enforceActionParams` was named by any test. Grep cannot settle that — a pin
* can live in a file that never names the function — so both were ABLATED
* instead, repo-wide against `packages/runtime`'s 217 files / 3143 tests:
*
* - `seedFlowActionParams`, gutted → **5 tests red** in
* `http-dispatcher.actions-type-dispatch.test.ts`. Pinned all along,
* indirectly, through the REST route. The claim was wrong about it.
* - `enforceActionParams`, replaced with an unconditional `return null` (the
* gate accepting every bag) → **3143 passed, 0 failed**. Nothing in the repo
* noticed the param contract had stopped existing.
*
* The VALIDATOR is thoroughly pinned — `@objectstack/spec`'s
* `action-params.test.ts` covers `validateActionParams` case by case. What had
* no pin is the runtime GATE wrapped around it, and the gate is where the
* decisions live that the validator never makes: the param-less pass-through,
* the strict-by-default rejection, and the `OS_ALLOW_LAX_ACTION_PARAMS` escape
* hatch. A green validator says nothing about whether anything still calls it.
*
* That gap matters more than a missing unit test usually does: this gate is
* what stops an AI/MCP caller's plausible-but-wrong bag from reaching an
* action body (#3438), and its only other mention outside the source is a
* MANUALLY-run platform-checklist clause. So it is pinned here at the level
* the ablation showed to be empty.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { enforceActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `enforceActionParams` reaches nothing on `deps` — it only forwards it. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

/** No parent object schema: an object-less action carrying inline params only. */
const NO_OBJECT = undefined;

const WHERE = { objectName: 'crm_lead', actionName: 'convert_lead' };

const REQUIRES_TITLE = {
name: 'convert_lead',
params: [{ name: 'title', type: 'text', required: true }],
};

describe('enforceActionParams — the ADR-0104 D2 gate (#14864)', () => {
beforeEach(() => {
vi.unstubAllEnvs();
});

afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('anti-vacuity control: a CONFORMING bag against declared params is accepted', () => {
// Positive control for the rejection below. Without it, a gate that
// rejected everything, or one that had stopped resolving params at
// all, would still satisfy "rejects a bad bag".
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, { title: 'Hi' }, WHERE)).toBeNull();
});

it('rejects a bag that violates the declared contract, naming the param', () => {
const error = enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE);
expect(error).toContain('Invalid action params');
expect(error).toContain('title');
});

it('passes an action that declares NO params straight through', () => {
// The documented compatibility leg: nothing to validate against, so a
// param-less action is untouched however odd its bag looks.
expect(enforceActionParams(NO_DEPS, { name: 'ping' }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
expect(enforceActionParams(NO_DEPS, { name: 'ping', params: [] }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
});

it('is STRICT by default — no environment variable needed to reject (#3438)', () => {
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '');
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE)).toContain('Invalid action params');
});

it('`OS_ALLOW_LAX_ACTION_PARAMS=1` accepts the same bag instead, and warns', () => {
// The opt-OUT of a check that ships ON (Prime Directive #9): the flag
// must change the ANSWER, not merely the log line — a flag that only
// logs would leave the rejection in place and strand the caller it was
// added to unblock.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '1');

// A dedup key this suite has not warned on yet — `warnActionParamsOnce`
// keys on `objectName/actionName` and its Set is module-global, so a
// reused key would make the warn assertion pass or fail on test order.
const where = { objectName: 'crm_lead', actionName: 'lax_probe' };
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, where)).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0])).toContain('OS_ALLOW_LAX_ACTION_PARAMS=1');
});
});
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
47 changes: 47 additions & 0 deletions .changeset/object-less-action-key-one-predicate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/runtime": patch
---

fix(runtime): route the flow param seeder through the single object-less predicate (#14864)

`isObjectLessActionKey` (`@objectstack/objectql`) is the canonical answer to
"is this routed object the object-less placeholder": the canonical
`GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
`dispatchFlowAction` asks it directly when it decides whether to hand the
automation service an `object` at all — and then, three lines later, handed the
same `objectName` to `seedFlowActionParams`, which answered the same question
with a second, narrower comparison of its own (`objectName !==
GLOBAL_ACTION_OBJECT_KEY`).

The two parted on exactly one input, `'*'`. A request routed at the legacy
wildcard — `POST /actions/*/<action>/<id>`, which resolves today because
`actionHandlerObjectKeys` deliberately probes `'*'` last so a handler user code
registered against it still resolves — was object-less to the automation
envelope (no `object` sent) and object-BOUND to the params bag, which seeded a
nonsense `'*Id'` key beside `recordId`. Same dispatch, two answers.

The empty-string half was never part of the divergence: the `objectName &&`
truthiness leg of the old guard already covered it, and `undefined` with it.
`'*'` was the whole of it.

**Direction.** The guard is widened onto the shared predicate rather than
`isObjectLessActionKey` being narrowed. `'*'` is *unused today*, not *dead*:
nothing first-party registers under it, but it is a deliberately-honoured
legacy read path with its own docblock, reachable through the public
`engine.registerAction(objectName, …)` surface that user code calls. Retiring
it is a compatibility decision about someone else's package, not a tidy-up this
fix is entitled to make.

**Coverage.** Both functions this touches were ablated repo-wide first rather
than grepped, because a grep scoped to the file you expect a pin in cannot see
a pin living elsewhere:

- `seedFlowActionParams` gutted → 5 tests red. It was pinned all along,
indirectly, through the REST route — but every case there routes at a real
object, so the object-LESS leg, where the two predicates actually disagreed,
was the unpinned part. Now pinned, over the whole predicate domain.
- `enforceActionParams` replaced with an unconditional `return null` → 3143
passed, 0 failed. The ADR-0104 D2 gate could stop existing with nothing in
the repo noticing. Its validator is well pinned in `@objectstack/spec`; the
runtime gate around it was not, and that gate is what keeps an AI/MCP
caller's plausible-but-wrong bag out of an action body. Now pinned.
11 changes: 10 additions & 1 deletion packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -638,7 +638,16 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps,

if (rowId != null) {
const keys = new Set<string>(['recordId']);
if (objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY) {
// [#14864] ONE predicate for "object-less", the same one
// `dispatchFlowAction` asks three lines from here before it decides
// whether to hand the automation service an `object` at all. This used
// to be a second, narrower comparison (`objectName !==
// GLOBAL_ACTION_OBJECT_KEY`), and the two parted on exactly one input:
// a route resolved at the legacy `'*'` was object-less to the envelope
// and object-BOUND here, so the bag grew a nonsense `'*Id'` alias. The
// empty-string leg was never the divergence — the `objectName &&`
// truthiness test this replaces already covered it.
if (!isObjectLessActionKey(objectName)) {
keys.add(`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`);
}
if (typeof action?.recordIdParam === 'string' && action.recordIdParam) {
Expand Down
119 changes: 119 additions & 0 deletions packages/runtime/src/action-object-less-key-agreement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* "Object-less" has ONE answer inside `action-execution.ts` (#14864).
*
* `isObjectLessActionKey` (`@objectstack/objectql`) is the canonical predicate:
* the routed object is object-less when it is the canonical
* `GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
* `dispatchFlowAction` asks it directly when it decides whether to hand the
* automation service an `object` at all — and then, on the very next line,
* hands the same `objectName` to `seedFlowActionParams`, which used to answer
* the same question with a second, narrower comparison of its own.
*
* The two parted on exactly one input, `'*'`: the automation envelope treated a
* `'*'` route as object-less and omitted `object`, while the params bag treated
* it as a real object and seeded a nonsense `'*Id'` alias key beside
* `recordId`. One dispatch, two answers, three lines apart.
*
* ## Why the pin sits HERE and not only on the route
*
* `seedFlowActionParams` was NOT unpinned — `http-dispatcher.actions-type-
* dispatch.test.ts` covers its whole seeding ladder, indirectly, through the
* REST route, without ever naming it. What that file never does is route at an
* object-LESS key: every case there is `/crm_lead/...`. So the ladder was
* pinned and the object-less leg of it was not, which is why the divergence
* survived. This file pins the leg, at the level the two predicates actually
* meet: one function, the whole `isObjectLessActionKey` domain, one bag.
*
* ## The arms, and which one is the control
*
* The `OBJECT_FUL` case is an ANTI-VACUITY CONTROL, not a pin: it asserts the
* alias key IS seeded for a real object. If a future edit makes
* `seedFlowActionParams` seed nothing at all, the negative assertions below
* would all pass for the wrong reason, and this control is what fails instead.
* ⛔ A red here is not this file's finding — read the object-less arm first.
*/

import { describe, it, expect } from 'vitest';
import { GLOBAL_ACTION_OBJECT_KEY, isObjectLessActionKey } from '@objectstack/objectql';
import { seedFlowActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `seedFlowActionParams` ignores its first parameter — see its signature. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

const ROW_ID = 'row_1';

/**
* The `<objectName>Id` camelCase alias `seedFlowActionParams` seeds for an
* object-bound route, derived the way the function derives it rather than
* hard-coded — a hard-coded copy would go stale in silence the day the
* spelling changes, which is the same failure this whole card is about.
*/
const aliasKeyFor = (objectName: string): string =>
`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`;

const seedFor = (objectName: string): Record<string, unknown> =>
seedFlowActionParams(NO_DEPS, { name: 'convert_lead', type: 'flow' }, {
objectName,
record: {},
params: {},
recordId: ROW_ID,
});

/**
* Every string spelling `isObjectLessActionKey` accepts. The table is asserted
* against the predicate itself below, so narrowing the predicate (retiring
* `'*'`, say) fails HERE with a readable message instead of quietly leaving a
* row that no longer describes anything.
*/
const OBJECT_LESS_KEYS: readonly string[] = [GLOBAL_ACTION_OBJECT_KEY, '*', ''];

/** A real object — the control's route, and the one the REST pin already uses. */
const OBJECT_FUL = 'crm_lead';

describe('object-less action key — one predicate, one answer (#14864)', () => {
it('anti-vacuity control: an object-BOUND route still seeds its alias key', () => {
// Positive control. Every negative below is a claim that a key is
// absent; without this, deleting the seeding branch outright would
// turn them all green.
expect(isObjectLessActionKey(OBJECT_FUL)).toBe(false);
const bag = seedFor(OBJECT_FUL);
expect(bag[aliasKeyFor(OBJECT_FUL)]).toBe(ROW_ID);
expect(bag.recordId).toBe(ROW_ID);
});

it('the table below describes exactly what the predicate accepts', () => {
// Guards the table, not the code: a narrowed predicate must come here
// and say so rather than leaving an inert row behind.
for (const key of OBJECT_LESS_KEYS) {
expect(isObjectLessActionKey(key), `${JSON.stringify(key)} is no longer object-less`).toBe(true);
}
});

it.each(OBJECT_LESS_KEYS.map((key) => ({ key, label: JSON.stringify(key) })))(
'seeds no object alias for the object-less key $label',
({ key }) => {
const bag = seedFor(key);
// The row id still reaches the flow — this is about the ALIAS only.
expect(bag.recordId).toBe(ROW_ID);
expect(
Object.keys(bag),
`seedFlowActionParams seeded the alias key ${JSON.stringify(aliasKeyFor(key))} for the `
+ `object-less route ${JSON.stringify(key)}. isObjectLessActionKey() calls that route `
+ `object-less and dispatchFlowAction omits \`object\` from the automation envelope for `
+ `it, so the params bag must not invent an object alias either (#14864).`,
).not.toContain(aliasKeyFor(key));
},
);

it('every object-less spelling lands the SAME bag as the canonical key', () => {
// The agreement stated as one assertion: which object-less spelling a
// caller routed at must not be observable in the flow's params.
const canonical = seedFor(GLOBAL_ACTION_OBJECT_KEY);
for (const key of OBJECT_LESS_KEYS) {
expect(seedFor(key), `routing at ${JSON.stringify(key)} produced a different params bag`)
.toEqual(canonical);
}
});
});
14 changes: 13 additions & 1 deletion packages/runtime/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -150,8 +150,20 @@ describe('standalone-action owner key — half C: no bare literal (#14678)', ()
// exactly the wrong reason — the can-never-fail property this whole
// file was written to replace. Both controls are positive assertions
// against text the converged file must carry.
//
// [#14864] The second control used to be the `seedFlowActionParams`
// comparison `objectName !== GLOBAL_ACTION_OBJECT_KEY`. That guard is
// gone — it was one of the two rival answers to "is this route
// object-less", and it now delegates to `isObjectLessActionKey` like
// its neighbours. Re-anchored rather than deleted, and deliberately
// onto a site this file's own subject does not move: the warn-once log
// key in `enforceActionParams`, which is the SECOND of the three bare
// literals #14678 converged and is untouched by the predicate work.
// ⛔ Do not re-anchor a control onto the thing the next change is most
// likely to edit — a control that moves with its subject stops being a
// control.
expect(src).toContain('GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('objectName !== GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('where.objectName ?? GLOBAL_ACTION_OBJECT_KEY');

for (const literal of BARE_LITERALS) {
expect(
Expand Down
102 changes: 102 additions & 0 deletions packages/runtime/src/action-params-enforcement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `enforceActionParams` — the ADR-0104 D2 gate itself, not its validator
* (#14864).
*
* ## What was measured, and why this file exists
*
* The card that produced this file claimed neither `seedFlowActionParams` nor
* `enforceActionParams` was named by any test. Grep cannot settle that — a pin
* can live in a file that never names the function — so both were ABLATED
* instead, repo-wide against `packages/runtime`'s 217 files / 3143 tests:
*
* - `seedFlowActionParams`, gutted → **5 tests red** in
* `http-dispatcher.actions-type-dispatch.test.ts`. Pinned all along,
* indirectly, through the REST route. The claim was wrong about it.
* - `enforceActionParams`, replaced with an unconditional `return null` (the
* gate accepting every bag) → **3143 passed, 0 failed**. Nothing in the repo
* noticed the param contract had stopped existing.
*
* The VALIDATOR is thoroughly pinned — `@objectstack/spec`'s
* `action-params.test.ts` covers `validateActionParams` case by case. What had
* no pin is the runtime GATE wrapped around it, and the gate is where the
* decisions live that the validator never makes: the param-less pass-through,
* the strict-by-default rejection, and the `OS_ALLOW_LAX_ACTION_PARAMS` escape
* hatch. A green validator says nothing about whether anything still calls it.
*
* That gap matters more than a missing unit test usually does: this gate is
* what stops an AI/MCP caller's plausible-but-wrong bag from reaching an
* action body (#3438), and its only other mention outside the source is a
* MANUALLY-run platform-checklist clause. So it is pinned here at the level
* the ablation showed to be empty.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { enforceActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `enforceActionParams` reaches nothing on `deps` — it only forwards it. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

/** No parent object schema: an object-less action carrying inline params only. */
const NO_OBJECT = undefined;

const WHERE = { objectName: 'crm_lead', actionName: 'convert_lead' };

const REQUIRES_TITLE = {
name: 'convert_lead',
params: [{ name: 'title', type: 'text', required: true }],
};

describe('enforceActionParams — the ADR-0104 D2 gate (#14864)', () => {
beforeEach(() => {
vi.unstubAllEnvs();
});

afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('anti-vacuity control: a CONFORMING bag against declared params is accepted', () => {
// Positive control for the rejection below. Without it, a gate that
// rejected everything, or one that had stopped resolving params at
// all, would still satisfy "rejects a bad bag".
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, { title: 'Hi' }, WHERE)).toBeNull();
});

it('rejects a bag that violates the declared contract, naming the param', () => {
const error = enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE);
expect(error).toContain('Invalid action params');
expect(error).toContain('title');
});

it('passes an action that declares NO params straight through', () => {
// The documented compatibility leg: nothing to validate against, so a
// param-less action is untouched however odd its bag looks.
expect(enforceActionParams(NO_DEPS, { name: 'ping' }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
expect(enforceActionParams(NO_DEPS, { name: 'ping', params: [] }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
});

it('is STRICT by default — no environment variable needed to reject (#3438)', () => {
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '');
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE)).toContain('Invalid action params');
});

it('`OS_ALLOW_LAX_ACTION_PARAMS=1` accepts the same bag instead, and warns', () => {
// The opt-OUT of a check that ships ON (Prime Directive #9): the flag
// must change the ANSWER, not merely the log line — a flag that only
// logs would leave the rejection in place and strand the caller it was
// added to unblock.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '1');

// A dedup key this suite has not warned on yet — `warnActionParamsOnce`
// keys on `objectName/actionName` and its Set is module-global, so a
// reused key would make the warn assertion pass or fail on test order.
const where = { objectName: 'crm_lead', actionName: 'lax_probe' };
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, where)).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0])).toContain('OS_ALLOW_LAX_ACTION_PARAMS=1');
});
});
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
47 changes: 47 additions & 0 deletions .changeset/object-less-action-key-one-predicate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/runtime": patch
---

fix(runtime): route the flow param seeder through the single object-less predicate (#14864)

`isObjectLessActionKey` (`@objectstack/objectql`) is the canonical answer to
"is this routed object the object-less placeholder": the canonical
`GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
`dispatchFlowAction` asks it directly when it decides whether to hand the
automation service an `object` at all — and then, three lines later, handed the
same `objectName` to `seedFlowActionParams`, which answered the same question
with a second, narrower comparison of its own (`objectName !==
GLOBAL_ACTION_OBJECT_KEY`).

The two parted on exactly one input, `'*'`. A request routed at the legacy
wildcard — `POST /actions/*/<action>/<id>`, which resolves today because
`actionHandlerObjectKeys` deliberately probes `'*'` last so a handler user code
registered against it still resolves — was object-less to the automation
envelope (no `object` sent) and object-BOUND to the params bag, which seeded a
nonsense `'*Id'` key beside `recordId`. Same dispatch, two answers.

The empty-string half was never part of the divergence: the `objectName &&`
truthiness leg of the old guard already covered it, and `undefined` with it.
`'*'` was the whole of it.

**Direction.** The guard is widened onto the shared predicate rather than
`isObjectLessActionKey` being narrowed. `'*'` is *unused today*, not *dead*:
nothing first-party registers under it, but it is a deliberately-honoured
legacy read path with its own docblock, reachable through the public
`engine.registerAction(objectName, …)` surface that user code calls. Retiring
it is a compatibility decision about someone else's package, not a tidy-up this
fix is entitled to make.

**Coverage.** Both functions this touches were ablated repo-wide first rather
than grepped, because a grep scoped to the file you expect a pin in cannot see
a pin living elsewhere:

- `seedFlowActionParams` gutted → 5 tests red. It was pinned all along,
indirectly, through the REST route — but every case there routes at a real
object, so the object-LESS leg, where the two predicates actually disagreed,
was the unpinned part. Now pinned, over the whole predicate domain.
- `enforceActionParams` replaced with an unconditional `return null` → 3143
passed, 0 failed. The ADR-0104 D2 gate could stop existing with nothing in
the repo noticing. Its validator is well pinned in `@objectstack/spec`; the
runtime gate around it was not, and that gate is what keeps an AI/MCP
caller's plausible-but-wrong bag out of an action body. Now pinned.
11 changes: 10 additions & 1 deletion packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -638,7 +638,16 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps,

if (rowId != null) {
const keys = new Set<string>(['recordId']);
if (objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY) {
// [#14864] ONE predicate for "object-less", the same one
// `dispatchFlowAction` asks three lines from here before it decides
// whether to hand the automation service an `object` at all. This used
// to be a second, narrower comparison (`objectName !==
// GLOBAL_ACTION_OBJECT_KEY`), and the two parted on exactly one input:
// a route resolved at the legacy `'*'` was object-less to the envelope
// and object-BOUND here, so the bag grew a nonsense `'*Id'` alias. The
// empty-string leg was never the divergence — the `objectName &&`
// truthiness test this replaces already covered it.
if (!isObjectLessActionKey(objectName)) {
keys.add(`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`);
}
if (typeof action?.recordIdParam === 'string' && action.recordIdParam) {
Expand Down
119 changes: 119 additions & 0 deletions packages/runtime/src/action-object-less-key-agreement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* "Object-less" has ONE answer inside `action-execution.ts` (#14864).
*
* `isObjectLessActionKey` (`@objectstack/objectql`) is the canonical predicate:
* the routed object is object-less when it is the canonical
* `GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
* `dispatchFlowAction` asks it directly when it decides whether to hand the
* automation service an `object` at all — and then, on the very next line,
* hands the same `objectName` to `seedFlowActionParams`, which used to answer
* the same question with a second, narrower comparison of its own.
*
* The two parted on exactly one input, `'*'`: the automation envelope treated a
* `'*'` route as object-less and omitted `object`, while the params bag treated
* it as a real object and seeded a nonsense `'*Id'` alias key beside
* `recordId`. One dispatch, two answers, three lines apart.
*
* ## Why the pin sits HERE and not only on the route
*
* `seedFlowActionParams` was NOT unpinned — `http-dispatcher.actions-type-
* dispatch.test.ts` covers its whole seeding ladder, indirectly, through the
* REST route, without ever naming it. What that file never does is route at an
* object-LESS key: every case there is `/crm_lead/...`. So the ladder was
* pinned and the object-less leg of it was not, which is why the divergence
* survived. This file pins the leg, at the level the two predicates actually
* meet: one function, the whole `isObjectLessActionKey` domain, one bag.
*
* ## The arms, and which one is the control
*
* The `OBJECT_FUL` case is an ANTI-VACUITY CONTROL, not a pin: it asserts the
* alias key IS seeded for a real object. If a future edit makes
* `seedFlowActionParams` seed nothing at all, the negative assertions below
* would all pass for the wrong reason, and this control is what fails instead.
* ⛔ A red here is not this file's finding — read the object-less arm first.
*/

import { describe, it, expect } from 'vitest';
import { GLOBAL_ACTION_OBJECT_KEY, isObjectLessActionKey } from '@objectstack/objectql';
import { seedFlowActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `seedFlowActionParams` ignores its first parameter — see its signature. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

const ROW_ID = 'row_1';

/**
* The `<objectName>Id` camelCase alias `seedFlowActionParams` seeds for an
* object-bound route, derived the way the function derives it rather than
* hard-coded — a hard-coded copy would go stale in silence the day the
* spelling changes, which is the same failure this whole card is about.
*/
const aliasKeyFor = (objectName: string): string =>
`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`;

const seedFor = (objectName: string): Record<string, unknown> =>
seedFlowActionParams(NO_DEPS, { name: 'convert_lead', type: 'flow' }, {
objectName,
record: {},
params: {},
recordId: ROW_ID,
});

/**
* Every string spelling `isObjectLessActionKey` accepts. The table is asserted
* against the predicate itself below, so narrowing the predicate (retiring
* `'*'`, say) fails HERE with a readable message instead of quietly leaving a
* row that no longer describes anything.
*/
const OBJECT_LESS_KEYS: readonly string[] = [GLOBAL_ACTION_OBJECT_KEY, '*', ''];

/** A real object — the control's route, and the one the REST pin already uses. */
const OBJECT_FUL = 'crm_lead';

describe('object-less action key — one predicate, one answer (#14864)', () => {
it('anti-vacuity control: an object-BOUND route still seeds its alias key', () => {
// Positive control. Every negative below is a claim that a key is
// absent; without this, deleting the seeding branch outright would
// turn them all green.
expect(isObjectLessActionKey(OBJECT_FUL)).toBe(false);
const bag = seedFor(OBJECT_FUL);
expect(bag[aliasKeyFor(OBJECT_FUL)]).toBe(ROW_ID);
expect(bag.recordId).toBe(ROW_ID);
});

it('the table below describes exactly what the predicate accepts', () => {
// Guards the table, not the code: a narrowed predicate must come here
// and say so rather than leaving an inert row behind.
for (const key of OBJECT_LESS_KEYS) {
expect(isObjectLessActionKey(key), `${JSON.stringify(key)} is no longer object-less`).toBe(true);
}
});

it.each(OBJECT_LESS_KEYS.map((key) => ({ key, label: JSON.stringify(key) })))(
'seeds no object alias for the object-less key $label',
({ key }) => {
const bag = seedFor(key);
// The row id still reaches the flow — this is about the ALIAS only.
expect(bag.recordId).toBe(ROW_ID);
expect(
Object.keys(bag),
`seedFlowActionParams seeded the alias key ${JSON.stringify(aliasKeyFor(key))} for the `
+ `object-less route ${JSON.stringify(key)}. isObjectLessActionKey() calls that route `
+ `object-less and dispatchFlowAction omits \`object\` from the automation envelope for `
+ `it, so the params bag must not invent an object alias either (#14864).`,
).not.toContain(aliasKeyFor(key));
},
);

it('every object-less spelling lands the SAME bag as the canonical key', () => {
// The agreement stated as one assertion: which object-less spelling a
// caller routed at must not be observable in the flow's params.
const canonical = seedFor(GLOBAL_ACTION_OBJECT_KEY);
for (const key of OBJECT_LESS_KEYS) {
expect(seedFor(key), `routing at ${JSON.stringify(key)} produced a different params bag`)
.toEqual(canonical);
}
});
});
14 changes: 13 additions & 1 deletion packages/runtime/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -150,8 +150,20 @@ describe('standalone-action owner key — half C: no bare literal (#14678)', ()
// exactly the wrong reason — the can-never-fail property this whole
// file was written to replace. Both controls are positive assertions
// against text the converged file must carry.
//
// [#14864] The second control used to be the `seedFlowActionParams`
// comparison `objectName !== GLOBAL_ACTION_OBJECT_KEY`. That guard is
// gone — it was one of the two rival answers to "is this route
// object-less", and it now delegates to `isObjectLessActionKey` like
// its neighbours. Re-anchored rather than deleted, and deliberately
// onto a site this file's own subject does not move: the warn-once log
// key in `enforceActionParams`, which is the SECOND of the three bare
// literals #14678 converged and is untouched by the predicate work.
// ⛔ Do not re-anchor a control onto the thing the next change is most
// likely to edit — a control that moves with its subject stops being a
// control.
expect(src).toContain('GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('objectName !== GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('where.objectName ?? GLOBAL_ACTION_OBJECT_KEY');

for (const literal of BARE_LITERALS) {
expect(
Expand Down
102 changes: 102 additions & 0 deletions packages/runtime/src/action-params-enforcement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `enforceActionParams` — the ADR-0104 D2 gate itself, not its validator
* (#14864).
*
* ## What was measured, and why this file exists
*
* The card that produced this file claimed neither `seedFlowActionParams` nor
* `enforceActionParams` was named by any test. Grep cannot settle that — a pin
* can live in a file that never names the function — so both were ABLATED
* instead, repo-wide against `packages/runtime`'s 217 files / 3143 tests:
*
* - `seedFlowActionParams`, gutted → **5 tests red** in
* `http-dispatcher.actions-type-dispatch.test.ts`. Pinned all along,
* indirectly, through the REST route. The claim was wrong about it.
* - `enforceActionParams`, replaced with an unconditional `return null` (the
* gate accepting every bag) → **3143 passed, 0 failed**. Nothing in the repo
* noticed the param contract had stopped existing.
*
* The VALIDATOR is thoroughly pinned — `@objectstack/spec`'s
* `action-params.test.ts` covers `validateActionParams` case by case. What had
* no pin is the runtime GATE wrapped around it, and the gate is where the
* decisions live that the validator never makes: the param-less pass-through,
* the strict-by-default rejection, and the `OS_ALLOW_LAX_ACTION_PARAMS` escape
* hatch. A green validator says nothing about whether anything still calls it.
*
* That gap matters more than a missing unit test usually does: this gate is
* what stops an AI/MCP caller's plausible-but-wrong bag from reaching an
* action body (#3438), and its only other mention outside the source is a
* MANUALLY-run platform-checklist clause. So it is pinned here at the level
* the ablation showed to be empty.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { enforceActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `enforceActionParams` reaches nothing on `deps` — it only forwards it. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

/** No parent object schema: an object-less action carrying inline params only. */
const NO_OBJECT = undefined;

const WHERE = { objectName: 'crm_lead', actionName: 'convert_lead' };

const REQUIRES_TITLE = {
name: 'convert_lead',
params: [{ name: 'title', type: 'text', required: true }],
};

describe('enforceActionParams — the ADR-0104 D2 gate (#14864)', () => {
beforeEach(() => {
vi.unstubAllEnvs();
});

afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('anti-vacuity control: a CONFORMING bag against declared params is accepted', () => {
// Positive control for the rejection below. Without it, a gate that
// rejected everything, or one that had stopped resolving params at
// all, would still satisfy "rejects a bad bag".
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, { title: 'Hi' }, WHERE)).toBeNull();
});

it('rejects a bag that violates the declared contract, naming the param', () => {
const error = enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE);
expect(error).toContain('Invalid action params');
expect(error).toContain('title');
});

it('passes an action that declares NO params straight through', () => {
// The documented compatibility leg: nothing to validate against, so a
// param-less action is untouched however odd its bag looks.
expect(enforceActionParams(NO_DEPS, { name: 'ping' }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
expect(enforceActionParams(NO_DEPS, { name: 'ping', params: [] }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
});

it('is STRICT by default — no environment variable needed to reject (#3438)', () => {
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '');
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE)).toContain('Invalid action params');
});

it('`OS_ALLOW_LAX_ACTION_PARAMS=1` accepts the same bag instead, and warns', () => {
// The opt-OUT of a check that ships ON (Prime Directive #9): the flag
// must change the ANSWER, not merely the log line — a flag that only
// logs would leave the rejection in place and strand the caller it was
// added to unblock.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '1');

// A dedup key this suite has not warned on yet — `warnActionParamsOnce`
// keys on `objectName/actionName` and its Set is module-global, so a
// reused key would make the warn assertion pass or fail on test order.
const where = { objectName: 'crm_lead', actionName: 'lax_probe' };
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, where)).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0])).toContain('OS_ALLOW_LAX_ACTION_PARAMS=1');
});
});
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
47 changes: 47 additions & 0 deletions .changeset/object-less-action-key-one-predicate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/runtime": patch
---

fix(runtime): route the flow param seeder through the single object-less predicate (#14864)

`isObjectLessActionKey` (`@objectstack/objectql`) is the canonical answer to
"is this routed object the object-less placeholder": the canonical
`GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
`dispatchFlowAction` asks it directly when it decides whether to hand the
automation service an `object` at all — and then, three lines later, handed the
same `objectName` to `seedFlowActionParams`, which answered the same question
with a second, narrower comparison of its own (`objectName !==
GLOBAL_ACTION_OBJECT_KEY`).

The two parted on exactly one input, `'*'`. A request routed at the legacy
wildcard — `POST /actions/*/<action>/<id>`, which resolves today because
`actionHandlerObjectKeys` deliberately probes `'*'` last so a handler user code
registered against it still resolves — was object-less to the automation
envelope (no `object` sent) and object-BOUND to the params bag, which seeded a
nonsense `'*Id'` key beside `recordId`. Same dispatch, two answers.

The empty-string half was never part of the divergence: the `objectName &&`
truthiness leg of the old guard already covered it, and `undefined` with it.
`'*'` was the whole of it.

**Direction.** The guard is widened onto the shared predicate rather than
`isObjectLessActionKey` being narrowed. `'*'` is *unused today*, not *dead*:
nothing first-party registers under it, but it is a deliberately-honoured
legacy read path with its own docblock, reachable through the public
`engine.registerAction(objectName, …)` surface that user code calls. Retiring
it is a compatibility decision about someone else's package, not a tidy-up this
fix is entitled to make.

**Coverage.** Both functions this touches were ablated repo-wide first rather
than grepped, because a grep scoped to the file you expect a pin in cannot see
a pin living elsewhere:

- `seedFlowActionParams` gutted → 5 tests red. It was pinned all along,
indirectly, through the REST route — but every case there routes at a real
object, so the object-LESS leg, where the two predicates actually disagreed,
was the unpinned part. Now pinned, over the whole predicate domain.
- `enforceActionParams` replaced with an unconditional `return null` → 3143
passed, 0 failed. The ADR-0104 D2 gate could stop existing with nothing in
the repo noticing. Its validator is well pinned in `@objectstack/spec`; the
runtime gate around it was not, and that gate is what keeps an AI/MCP
caller's plausible-but-wrong bag out of an action body. Now pinned.
11 changes: 10 additions & 1 deletion packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -638,7 +638,16 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps,

if (rowId != null) {
const keys = new Set<string>(['recordId']);
if (objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY) {
// [#14864] ONE predicate for "object-less", the same one
// `dispatchFlowAction` asks three lines from here before it decides
// whether to hand the automation service an `object` at all. This used
// to be a second, narrower comparison (`objectName !==
// GLOBAL_ACTION_OBJECT_KEY`), and the two parted on exactly one input:
// a route resolved at the legacy `'*'` was object-less to the envelope
// and object-BOUND here, so the bag grew a nonsense `'*Id'` alias. The
// empty-string leg was never the divergence — the `objectName &&`
// truthiness test this replaces already covered it.
if (!isObjectLessActionKey(objectName)) {
keys.add(`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`);
}
if (typeof action?.recordIdParam === 'string' && action.recordIdParam) {
Expand Down
119 changes: 119 additions & 0 deletions packages/runtime/src/action-object-less-key-agreement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* "Object-less" has ONE answer inside `action-execution.ts` (#14864).
*
* `isObjectLessActionKey` (`@objectstack/objectql`) is the canonical predicate:
* the routed object is object-less when it is the canonical
* `GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
* `dispatchFlowAction` asks it directly when it decides whether to hand the
* automation service an `object` at all — and then, on the very next line,
* hands the same `objectName` to `seedFlowActionParams`, which used to answer
* the same question with a second, narrower comparison of its own.
*
* The two parted on exactly one input, `'*'`: the automation envelope treated a
* `'*'` route as object-less and omitted `object`, while the params bag treated
* it as a real object and seeded a nonsense `'*Id'` alias key beside
* `recordId`. One dispatch, two answers, three lines apart.
*
* ## Why the pin sits HERE and not only on the route
*
* `seedFlowActionParams` was NOT unpinned — `http-dispatcher.actions-type-
* dispatch.test.ts` covers its whole seeding ladder, indirectly, through the
* REST route, without ever naming it. What that file never does is route at an
* object-LESS key: every case there is `/crm_lead/...`. So the ladder was
* pinned and the object-less leg of it was not, which is why the divergence
* survived. This file pins the leg, at the level the two predicates actually
* meet: one function, the whole `isObjectLessActionKey` domain, one bag.
*
* ## The arms, and which one is the control
*
* The `OBJECT_FUL` case is an ANTI-VACUITY CONTROL, not a pin: it asserts the
* alias key IS seeded for a real object. If a future edit makes
* `seedFlowActionParams` seed nothing at all, the negative assertions below
* would all pass for the wrong reason, and this control is what fails instead.
* ⛔ A red here is not this file's finding — read the object-less arm first.
*/

import { describe, it, expect } from 'vitest';
import { GLOBAL_ACTION_OBJECT_KEY, isObjectLessActionKey } from '@objectstack/objectql';
import { seedFlowActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `seedFlowActionParams` ignores its first parameter — see its signature. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

const ROW_ID = 'row_1';

/**
* The `<objectName>Id` camelCase alias `seedFlowActionParams` seeds for an
* object-bound route, derived the way the function derives it rather than
* hard-coded — a hard-coded copy would go stale in silence the day the
* spelling changes, which is the same failure this whole card is about.
*/
const aliasKeyFor = (objectName: string): string =>
`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`;

const seedFor = (objectName: string): Record<string, unknown> =>
seedFlowActionParams(NO_DEPS, { name: 'convert_lead', type: 'flow' }, {
objectName,
record: {},
params: {},
recordId: ROW_ID,
});

/**
* Every string spelling `isObjectLessActionKey` accepts. The table is asserted
* against the predicate itself below, so narrowing the predicate (retiring
* `'*'`, say) fails HERE with a readable message instead of quietly leaving a
* row that no longer describes anything.
*/
const OBJECT_LESS_KEYS: readonly string[] = [GLOBAL_ACTION_OBJECT_KEY, '*', ''];

/** A real object — the control's route, and the one the REST pin already uses. */
const OBJECT_FUL = 'crm_lead';

describe('object-less action key — one predicate, one answer (#14864)', () => {
it('anti-vacuity control: an object-BOUND route still seeds its alias key', () => {
// Positive control. Every negative below is a claim that a key is
// absent; without this, deleting the seeding branch outright would
// turn them all green.
expect(isObjectLessActionKey(OBJECT_FUL)).toBe(false);
const bag = seedFor(OBJECT_FUL);
expect(bag[aliasKeyFor(OBJECT_FUL)]).toBe(ROW_ID);
expect(bag.recordId).toBe(ROW_ID);
});

it('the table below describes exactly what the predicate accepts', () => {
// Guards the table, not the code: a narrowed predicate must come here
// and say so rather than leaving an inert row behind.
for (const key of OBJECT_LESS_KEYS) {
expect(isObjectLessActionKey(key), `${JSON.stringify(key)} is no longer object-less`).toBe(true);
}
});

it.each(OBJECT_LESS_KEYS.map((key) => ({ key, label: JSON.stringify(key) })))(
'seeds no object alias for the object-less key $label',
({ key }) => {
const bag = seedFor(key);
// The row id still reaches the flow — this is about the ALIAS only.
expect(bag.recordId).toBe(ROW_ID);
expect(
Object.keys(bag),
`seedFlowActionParams seeded the alias key ${JSON.stringify(aliasKeyFor(key))} for the `
+ `object-less route ${JSON.stringify(key)}. isObjectLessActionKey() calls that route `
+ `object-less and dispatchFlowAction omits \`object\` from the automation envelope for `
+ `it, so the params bag must not invent an object alias either (#14864).`,
).not.toContain(aliasKeyFor(key));
},
);

it('every object-less spelling lands the SAME bag as the canonical key', () => {
// The agreement stated as one assertion: which object-less spelling a
// caller routed at must not be observable in the flow's params.
const canonical = seedFor(GLOBAL_ACTION_OBJECT_KEY);
for (const key of OBJECT_LESS_KEYS) {
expect(seedFor(key), `routing at ${JSON.stringify(key)} produced a different params bag`)
.toEqual(canonical);
}
});
});
14 changes: 13 additions & 1 deletion packages/runtime/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -150,8 +150,20 @@ describe('standalone-action owner key — half C: no bare literal (#14678)', ()
// exactly the wrong reason — the can-never-fail property this whole
// file was written to replace. Both controls are positive assertions
// against text the converged file must carry.
//
// [#14864] The second control used to be the `seedFlowActionParams`
// comparison `objectName !== GLOBAL_ACTION_OBJECT_KEY`. That guard is
// gone — it was one of the two rival answers to "is this route
// object-less", and it now delegates to `isObjectLessActionKey` like
// its neighbours. Re-anchored rather than deleted, and deliberately
// onto a site this file's own subject does not move: the warn-once log
// key in `enforceActionParams`, which is the SECOND of the three bare
// literals #14678 converged and is untouched by the predicate work.
// ⛔ Do not re-anchor a control onto the thing the next change is most
// likely to edit — a control that moves with its subject stops being a
// control.
expect(src).toContain('GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('objectName !== GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('where.objectName ?? GLOBAL_ACTION_OBJECT_KEY');

for (const literal of BARE_LITERALS) {
expect(
Expand Down
102 changes: 102 additions & 0 deletions packages/runtime/src/action-params-enforcement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `enforceActionParams` — the ADR-0104 D2 gate itself, not its validator
* (#14864).
*
* ## What was measured, and why this file exists
*
* The card that produced this file claimed neither `seedFlowActionParams` nor
* `enforceActionParams` was named by any test. Grep cannot settle that — a pin
* can live in a file that never names the function — so both were ABLATED
* instead, repo-wide against `packages/runtime`'s 217 files / 3143 tests:
*
* - `seedFlowActionParams`, gutted → **5 tests red** in
* `http-dispatcher.actions-type-dispatch.test.ts`. Pinned all along,
* indirectly, through the REST route. The claim was wrong about it.
* - `enforceActionParams`, replaced with an unconditional `return null` (the
* gate accepting every bag) → **3143 passed, 0 failed**. Nothing in the repo
* noticed the param contract had stopped existing.
*
* The VALIDATOR is thoroughly pinned — `@objectstack/spec`'s
* `action-params.test.ts` covers `validateActionParams` case by case. What had
* no pin is the runtime GATE wrapped around it, and the gate is where the
* decisions live that the validator never makes: the param-less pass-through,
* the strict-by-default rejection, and the `OS_ALLOW_LAX_ACTION_PARAMS` escape
* hatch. A green validator says nothing about whether anything still calls it.
*
* That gap matters more than a missing unit test usually does: this gate is
* what stops an AI/MCP caller's plausible-but-wrong bag from reaching an
* action body (#3438), and its only other mention outside the source is a
* MANUALLY-run platform-checklist clause. So it is pinned here at the level
* the ablation showed to be empty.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { enforceActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `enforceActionParams` reaches nothing on `deps` — it only forwards it. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

/** No parent object schema: an object-less action carrying inline params only. */
const NO_OBJECT = undefined;

const WHERE = { objectName: 'crm_lead', actionName: 'convert_lead' };

const REQUIRES_TITLE = {
name: 'convert_lead',
params: [{ name: 'title', type: 'text', required: true }],
};

describe('enforceActionParams — the ADR-0104 D2 gate (#14864)', () => {
beforeEach(() => {
vi.unstubAllEnvs();
});

afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('anti-vacuity control: a CONFORMING bag against declared params is accepted', () => {
// Positive control for the rejection below. Without it, a gate that
// rejected everything, or one that had stopped resolving params at
// all, would still satisfy "rejects a bad bag".
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, { title: 'Hi' }, WHERE)).toBeNull();
});

it('rejects a bag that violates the declared contract, naming the param', () => {
const error = enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE);
expect(error).toContain('Invalid action params');
expect(error).toContain('title');
});

it('passes an action that declares NO params straight through', () => {
// The documented compatibility leg: nothing to validate against, so a
// param-less action is untouched however odd its bag looks.
expect(enforceActionParams(NO_DEPS, { name: 'ping' }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
expect(enforceActionParams(NO_DEPS, { name: 'ping', params: [] }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
});

it('is STRICT by default — no environment variable needed to reject (#3438)', () => {
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '');
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE)).toContain('Invalid action params');
});

it('`OS_ALLOW_LAX_ACTION_PARAMS=1` accepts the same bag instead, and warns', () => {
// The opt-OUT of a check that ships ON (Prime Directive #9): the flag
// must change the ANSWER, not merely the log line — a flag that only
// logs would leave the rejection in place and strand the caller it was
// added to unblock.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '1');

// A dedup key this suite has not warned on yet — `warnActionParamsOnce`
// keys on `objectName/actionName` and its Set is module-global, so a
// reused key would make the warn assertion pass or fail on test order.
const where = { objectName: 'crm_lead', actionName: 'lax_probe' };
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, where)).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0])).toContain('OS_ALLOW_LAX_ACTION_PARAMS=1');
});
});
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
47 changes: 47 additions & 0 deletions .changeset/object-less-action-key-one-predicate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/runtime": patch
---

fix(runtime): route the flow param seeder through the single object-less predicate (#14864)

`isObjectLessActionKey` (`@objectstack/objectql`) is the canonical answer to
"is this routed object the object-less placeholder": the canonical
`GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
`dispatchFlowAction` asks it directly when it decides whether to hand the
automation service an `object` at all — and then, three lines later, handed the
same `objectName` to `seedFlowActionParams`, which answered the same question
with a second, narrower comparison of its own (`objectName !==
GLOBAL_ACTION_OBJECT_KEY`).

The two parted on exactly one input, `'*'`. A request routed at the legacy
wildcard — `POST /actions/*/<action>/<id>`, which resolves today because
`actionHandlerObjectKeys` deliberately probes `'*'` last so a handler user code
registered against it still resolves — was object-less to the automation
envelope (no `object` sent) and object-BOUND to the params bag, which seeded a
nonsense `'*Id'` key beside `recordId`. Same dispatch, two answers.

The empty-string half was never part of the divergence: the `objectName &&`
truthiness leg of the old guard already covered it, and `undefined` with it.
`'*'` was the whole of it.

**Direction.** The guard is widened onto the shared predicate rather than
`isObjectLessActionKey` being narrowed. `'*'` is *unused today*, not *dead*:
nothing first-party registers under it, but it is a deliberately-honoured
legacy read path with its own docblock, reachable through the public
`engine.registerAction(objectName, …)` surface that user code calls. Retiring
it is a compatibility decision about someone else's package, not a tidy-up this
fix is entitled to make.

**Coverage.** Both functions this touches were ablated repo-wide first rather
than grepped, because a grep scoped to the file you expect a pin in cannot see
a pin living elsewhere:

- `seedFlowActionParams` gutted → 5 tests red. It was pinned all along,
indirectly, through the REST route — but every case there routes at a real
object, so the object-LESS leg, where the two predicates actually disagreed,
was the unpinned part. Now pinned, over the whole predicate domain.
- `enforceActionParams` replaced with an unconditional `return null` → 3143
passed, 0 failed. The ADR-0104 D2 gate could stop existing with nothing in
the repo noticing. Its validator is well pinned in `@objectstack/spec`; the
runtime gate around it was not, and that gate is what keeps an AI/MCP
caller's plausible-but-wrong bag out of an action body. Now pinned.
11 changes: 10 additions & 1 deletion packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -638,7 +638,16 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps,

if (rowId != null) {
const keys = new Set<string>(['recordId']);
if (objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY) {
// [#14864] ONE predicate for "object-less", the same one
// `dispatchFlowAction` asks three lines from here before it decides
// whether to hand the automation service an `object` at all. This used
// to be a second, narrower comparison (`objectName !==
// GLOBAL_ACTION_OBJECT_KEY`), and the two parted on exactly one input:
// a route resolved at the legacy `'*'` was object-less to the envelope
// and object-BOUND here, so the bag grew a nonsense `'*Id'` alias. The
// empty-string leg was never the divergence — the `objectName &&`
// truthiness test this replaces already covered it.
if (!isObjectLessActionKey(objectName)) {
keys.add(`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`);
}
if (typeof action?.recordIdParam === 'string' && action.recordIdParam) {
Expand Down
119 changes: 119 additions & 0 deletions packages/runtime/src/action-object-less-key-agreement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* "Object-less" has ONE answer inside `action-execution.ts` (#14864).
*
* `isObjectLessActionKey` (`@objectstack/objectql`) is the canonical predicate:
* the routed object is object-less when it is the canonical
* `GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
* `dispatchFlowAction` asks it directly when it decides whether to hand the
* automation service an `object` at all — and then, on the very next line,
* hands the same `objectName` to `seedFlowActionParams`, which used to answer
* the same question with a second, narrower comparison of its own.
*
* The two parted on exactly one input, `'*'`: the automation envelope treated a
* `'*'` route as object-less and omitted `object`, while the params bag treated
* it as a real object and seeded a nonsense `'*Id'` alias key beside
* `recordId`. One dispatch, two answers, three lines apart.
*
* ## Why the pin sits HERE and not only on the route
*
* `seedFlowActionParams` was NOT unpinned — `http-dispatcher.actions-type-
* dispatch.test.ts` covers its whole seeding ladder, indirectly, through the
* REST route, without ever naming it. What that file never does is route at an
* object-LESS key: every case there is `/crm_lead/...`. So the ladder was
* pinned and the object-less leg of it was not, which is why the divergence
* survived. This file pins the leg, at the level the two predicates actually
* meet: one function, the whole `isObjectLessActionKey` domain, one bag.
*
* ## The arms, and which one is the control
*
* The `OBJECT_FUL` case is an ANTI-VACUITY CONTROL, not a pin: it asserts the
* alias key IS seeded for a real object. If a future edit makes
* `seedFlowActionParams` seed nothing at all, the negative assertions below
* would all pass for the wrong reason, and this control is what fails instead.
* ⛔ A red here is not this file's finding — read the object-less arm first.
*/

import { describe, it, expect } from 'vitest';
import { GLOBAL_ACTION_OBJECT_KEY, isObjectLessActionKey } from '@objectstack/objectql';
import { seedFlowActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `seedFlowActionParams` ignores its first parameter — see its signature. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

const ROW_ID = 'row_1';

/**
* The `<objectName>Id` camelCase alias `seedFlowActionParams` seeds for an
* object-bound route, derived the way the function derives it rather than
* hard-coded — a hard-coded copy would go stale in silence the day the
* spelling changes, which is the same failure this whole card is about.
*/
const aliasKeyFor = (objectName: string): string =>
`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`;

const seedFor = (objectName: string): Record<string, unknown> =>
seedFlowActionParams(NO_DEPS, { name: 'convert_lead', type: 'flow' }, {
objectName,
record: {},
params: {},
recordId: ROW_ID,
});

/**
* Every string spelling `isObjectLessActionKey` accepts. The table is asserted
* against the predicate itself below, so narrowing the predicate (retiring
* `'*'`, say) fails HERE with a readable message instead of quietly leaving a
* row that no longer describes anything.
*/
const OBJECT_LESS_KEYS: readonly string[] = [GLOBAL_ACTION_OBJECT_KEY, '*', ''];

/** A real object — the control's route, and the one the REST pin already uses. */
const OBJECT_FUL = 'crm_lead';

describe('object-less action key — one predicate, one answer (#14864)', () => {
it('anti-vacuity control: an object-BOUND route still seeds its alias key', () => {
// Positive control. Every negative below is a claim that a key is
// absent; without this, deleting the seeding branch outright would
// turn them all green.
expect(isObjectLessActionKey(OBJECT_FUL)).toBe(false);
const bag = seedFor(OBJECT_FUL);
expect(bag[aliasKeyFor(OBJECT_FUL)]).toBe(ROW_ID);
expect(bag.recordId).toBe(ROW_ID);
});

it('the table below describes exactly what the predicate accepts', () => {
// Guards the table, not the code: a narrowed predicate must come here
// and say so rather than leaving an inert row behind.
for (const key of OBJECT_LESS_KEYS) {
expect(isObjectLessActionKey(key), `${JSON.stringify(key)} is no longer object-less`).toBe(true);
}
});

it.each(OBJECT_LESS_KEYS.map((key) => ({ key, label: JSON.stringify(key) })))(
'seeds no object alias for the object-less key $label',
({ key }) => {
const bag = seedFor(key);
// The row id still reaches the flow — this is about the ALIAS only.
expect(bag.recordId).toBe(ROW_ID);
expect(
Object.keys(bag),
`seedFlowActionParams seeded the alias key ${JSON.stringify(aliasKeyFor(key))} for the `
+ `object-less route ${JSON.stringify(key)}. isObjectLessActionKey() calls that route `
+ `object-less and dispatchFlowAction omits \`object\` from the automation envelope for `
+ `it, so the params bag must not invent an object alias either (#14864).`,
).not.toContain(aliasKeyFor(key));
},
);

it('every object-less spelling lands the SAME bag as the canonical key', () => {
// The agreement stated as one assertion: which object-less spelling a
// caller routed at must not be observable in the flow's params.
const canonical = seedFor(GLOBAL_ACTION_OBJECT_KEY);
for (const key of OBJECT_LESS_KEYS) {
expect(seedFor(key), `routing at ${JSON.stringify(key)} produced a different params bag`)
.toEqual(canonical);
}
});
});
14 changes: 13 additions & 1 deletion packages/runtime/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -150,8 +150,20 @@ describe('standalone-action owner key — half C: no bare literal (#14678)', ()
// exactly the wrong reason — the can-never-fail property this whole
// file was written to replace. Both controls are positive assertions
// against text the converged file must carry.
//
// [#14864] The second control used to be the `seedFlowActionParams`
// comparison `objectName !== GLOBAL_ACTION_OBJECT_KEY`. That guard is
// gone — it was one of the two rival answers to "is this route
// object-less", and it now delegates to `isObjectLessActionKey` like
// its neighbours. Re-anchored rather than deleted, and deliberately
// onto a site this file's own subject does not move: the warn-once log
// key in `enforceActionParams`, which is the SECOND of the three bare
// literals #14678 converged and is untouched by the predicate work.
// ⛔ Do not re-anchor a control onto the thing the next change is most
// likely to edit — a control that moves with its subject stops being a
// control.
expect(src).toContain('GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('objectName !== GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('where.objectName ?? GLOBAL_ACTION_OBJECT_KEY');

for (const literal of BARE_LITERALS) {
expect(
Expand Down
102 changes: 102 additions & 0 deletions packages/runtime/src/action-params-enforcement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `enforceActionParams` — the ADR-0104 D2 gate itself, not its validator
* (#14864).
*
* ## What was measured, and why this file exists
*
* The card that produced this file claimed neither `seedFlowActionParams` nor
* `enforceActionParams` was named by any test. Grep cannot settle that — a pin
* can live in a file that never names the function — so both were ABLATED
* instead, repo-wide against `packages/runtime`'s 217 files / 3143 tests:
*
* - `seedFlowActionParams`, gutted → **5 tests red** in
* `http-dispatcher.actions-type-dispatch.test.ts`. Pinned all along,
* indirectly, through the REST route. The claim was wrong about it.
* - `enforceActionParams`, replaced with an unconditional `return null` (the
* gate accepting every bag) → **3143 passed, 0 failed**. Nothing in the repo
* noticed the param contract had stopped existing.
*
* The VALIDATOR is thoroughly pinned — `@objectstack/spec`'s
* `action-params.test.ts` covers `validateActionParams` case by case. What had
* no pin is the runtime GATE wrapped around it, and the gate is where the
* decisions live that the validator never makes: the param-less pass-through,
* the strict-by-default rejection, and the `OS_ALLOW_LAX_ACTION_PARAMS` escape
* hatch. A green validator says nothing about whether anything still calls it.
*
* That gap matters more than a missing unit test usually does: this gate is
* what stops an AI/MCP caller's plausible-but-wrong bag from reaching an
* action body (#3438), and its only other mention outside the source is a
* MANUALLY-run platform-checklist clause. So it is pinned here at the level
* the ablation showed to be empty.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { enforceActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `enforceActionParams` reaches nothing on `deps` — it only forwards it. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

/** No parent object schema: an object-less action carrying inline params only. */
const NO_OBJECT = undefined;

const WHERE = { objectName: 'crm_lead', actionName: 'convert_lead' };

const REQUIRES_TITLE = {
name: 'convert_lead',
params: [{ name: 'title', type: 'text', required: true }],
};

describe('enforceActionParams — the ADR-0104 D2 gate (#14864)', () => {
beforeEach(() => {
vi.unstubAllEnvs();
});

afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('anti-vacuity control: a CONFORMING bag against declared params is accepted', () => {
// Positive control for the rejection below. Without it, a gate that
// rejected everything, or one that had stopped resolving params at
// all, would still satisfy "rejects a bad bag".
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, { title: 'Hi' }, WHERE)).toBeNull();
});

it('rejects a bag that violates the declared contract, naming the param', () => {
const error = enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE);
expect(error).toContain('Invalid action params');
expect(error).toContain('title');
});

it('passes an action that declares NO params straight through', () => {
// The documented compatibility leg: nothing to validate against, so a
// param-less action is untouched however odd its bag looks.
expect(enforceActionParams(NO_DEPS, { name: 'ping' }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
expect(enforceActionParams(NO_DEPS, { name: 'ping', params: [] }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
});

it('is STRICT by default — no environment variable needed to reject (#3438)', () => {
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '');
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE)).toContain('Invalid action params');
});

it('`OS_ALLOW_LAX_ACTION_PARAMS=1` accepts the same bag instead, and warns', () => {
// The opt-OUT of a check that ships ON (Prime Directive #9): the flag
// must change the ANSWER, not merely the log line — a flag that only
// logs would leave the rejection in place and strand the caller it was
// added to unblock.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '1');

// A dedup key this suite has not warned on yet — `warnActionParamsOnce`
// keys on `objectName/actionName` and its Set is module-global, so a
// reused key would make the warn assertion pass or fail on test order.
const where = { objectName: 'crm_lead', actionName: 'lax_probe' };
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, where)).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0])).toContain('OS_ALLOW_LAX_ACTION_PARAMS=1');
});
});
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
47 changes: 47 additions & 0 deletions .changeset/object-less-action-key-one-predicate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/runtime": patch
---

fix(runtime): route the flow param seeder through the single object-less predicate (#14864)

`isObjectLessActionKey` (`@objectstack/objectql`) is the canonical answer to
"is this routed object the object-less placeholder": the canonical
`GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
`dispatchFlowAction` asks it directly when it decides whether to hand the
automation service an `object` at all — and then, three lines later, handed the
same `objectName` to `seedFlowActionParams`, which answered the same question
with a second, narrower comparison of its own (`objectName !==
GLOBAL_ACTION_OBJECT_KEY`).

The two parted on exactly one input, `'*'`. A request routed at the legacy
wildcard — `POST /actions/*/<action>/<id>`, which resolves today because
`actionHandlerObjectKeys` deliberately probes `'*'` last so a handler user code
registered against it still resolves — was object-less to the automation
envelope (no `object` sent) and object-BOUND to the params bag, which seeded a
nonsense `'*Id'` key beside `recordId`. Same dispatch, two answers.

The empty-string half was never part of the divergence: the `objectName &&`
truthiness leg of the old guard already covered it, and `undefined` with it.
`'*'` was the whole of it.

**Direction.** The guard is widened onto the shared predicate rather than
`isObjectLessActionKey` being narrowed. `'*'` is *unused today*, not *dead*:
nothing first-party registers under it, but it is a deliberately-honoured
legacy read path with its own docblock, reachable through the public
`engine.registerAction(objectName, …)` surface that user code calls. Retiring
it is a compatibility decision about someone else's package, not a tidy-up this
fix is entitled to make.

**Coverage.** Both functions this touches were ablated repo-wide first rather
than grepped, because a grep scoped to the file you expect a pin in cannot see
a pin living elsewhere:

- `seedFlowActionParams` gutted → 5 tests red. It was pinned all along,
indirectly, through the REST route — but every case there routes at a real
object, so the object-LESS leg, where the two predicates actually disagreed,
was the unpinned part. Now pinned, over the whole predicate domain.
- `enforceActionParams` replaced with an unconditional `return null` → 3143
passed, 0 failed. The ADR-0104 D2 gate could stop existing with nothing in
the repo noticing. Its validator is well pinned in `@objectstack/spec`; the
runtime gate around it was not, and that gate is what keeps an AI/MCP
caller's plausible-but-wrong bag out of an action body. Now pinned.
11 changes: 10 additions & 1 deletion packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -638,7 +638,16 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps,

if (rowId != null) {
const keys = new Set<string>(['recordId']);
if (objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY) {
// [#14864] ONE predicate for "object-less", the same one
// `dispatchFlowAction` asks three lines from here before it decides
// whether to hand the automation service an `object` at all. This used
// to be a second, narrower comparison (`objectName !==
// GLOBAL_ACTION_OBJECT_KEY`), and the two parted on exactly one input:
// a route resolved at the legacy `'*'` was object-less to the envelope
// and object-BOUND here, so the bag grew a nonsense `'*Id'` alias. The
// empty-string leg was never the divergence — the `objectName &&`
// truthiness test this replaces already covered it.
if (!isObjectLessActionKey(objectName)) {
keys.add(`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`);
}
if (typeof action?.recordIdParam === 'string' && action.recordIdParam) {
Expand Down
119 changes: 119 additions & 0 deletions packages/runtime/src/action-object-less-key-agreement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* "Object-less" has ONE answer inside `action-execution.ts` (#14864).
*
* `isObjectLessActionKey` (`@objectstack/objectql`) is the canonical predicate:
* the routed object is object-less when it is the canonical
* `GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
* `dispatchFlowAction` asks it directly when it decides whether to hand the
* automation service an `object` at all — and then, on the very next line,
* hands the same `objectName` to `seedFlowActionParams`, which used to answer
* the same question with a second, narrower comparison of its own.
*
* The two parted on exactly one input, `'*'`: the automation envelope treated a
* `'*'` route as object-less and omitted `object`, while the params bag treated
* it as a real object and seeded a nonsense `'*Id'` alias key beside
* `recordId`. One dispatch, two answers, three lines apart.
*
* ## Why the pin sits HERE and not only on the route
*
* `seedFlowActionParams` was NOT unpinned — `http-dispatcher.actions-type-
* dispatch.test.ts` covers its whole seeding ladder, indirectly, through the
* REST route, without ever naming it. What that file never does is route at an
* object-LESS key: every case there is `/crm_lead/...`. So the ladder was
* pinned and the object-less leg of it was not, which is why the divergence
* survived. This file pins the leg, at the level the two predicates actually
* meet: one function, the whole `isObjectLessActionKey` domain, one bag.
*
* ## The arms, and which one is the control
*
* The `OBJECT_FUL` case is an ANTI-VACUITY CONTROL, not a pin: it asserts the
* alias key IS seeded for a real object. If a future edit makes
* `seedFlowActionParams` seed nothing at all, the negative assertions below
* would all pass for the wrong reason, and this control is what fails instead.
* ⛔ A red here is not this file's finding — read the object-less arm first.
*/

import { describe, it, expect } from 'vitest';
import { GLOBAL_ACTION_OBJECT_KEY, isObjectLessActionKey } from '@objectstack/objectql';
import { seedFlowActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `seedFlowActionParams` ignores its first parameter — see its signature. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

const ROW_ID = 'row_1';

/**
* The `<objectName>Id` camelCase alias `seedFlowActionParams` seeds for an
* object-bound route, derived the way the function derives it rather than
* hard-coded — a hard-coded copy would go stale in silence the day the
* spelling changes, which is the same failure this whole card is about.
*/
const aliasKeyFor = (objectName: string): string =>
`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`;

const seedFor = (objectName: string): Record<string, unknown> =>
seedFlowActionParams(NO_DEPS, { name: 'convert_lead', type: 'flow' }, {
objectName,
record: {},
params: {},
recordId: ROW_ID,
});

/**
* Every string spelling `isObjectLessActionKey` accepts. The table is asserted
* against the predicate itself below, so narrowing the predicate (retiring
* `'*'`, say) fails HERE with a readable message instead of quietly leaving a
* row that no longer describes anything.
*/
const OBJECT_LESS_KEYS: readonly string[] = [GLOBAL_ACTION_OBJECT_KEY, '*', ''];

/** A real object — the control's route, and the one the REST pin already uses. */
const OBJECT_FUL = 'crm_lead';

describe('object-less action key — one predicate, one answer (#14864)', () => {
it('anti-vacuity control: an object-BOUND route still seeds its alias key', () => {
// Positive control. Every negative below is a claim that a key is
// absent; without this, deleting the seeding branch outright would
// turn them all green.
expect(isObjectLessActionKey(OBJECT_FUL)).toBe(false);
const bag = seedFor(OBJECT_FUL);
expect(bag[aliasKeyFor(OBJECT_FUL)]).toBe(ROW_ID);
expect(bag.recordId).toBe(ROW_ID);
});

it('the table below describes exactly what the predicate accepts', () => {
// Guards the table, not the code: a narrowed predicate must come here
// and say so rather than leaving an inert row behind.
for (const key of OBJECT_LESS_KEYS) {
expect(isObjectLessActionKey(key), `${JSON.stringify(key)} is no longer object-less`).toBe(true);
}
});

it.each(OBJECT_LESS_KEYS.map((key) => ({ key, label: JSON.stringify(key) })))(
'seeds no object alias for the object-less key $label',
({ key }) => {
const bag = seedFor(key);
// The row id still reaches the flow — this is about the ALIAS only.
expect(bag.recordId).toBe(ROW_ID);
expect(
Object.keys(bag),
`seedFlowActionParams seeded the alias key ${JSON.stringify(aliasKeyFor(key))} for the `
+ `object-less route ${JSON.stringify(key)}. isObjectLessActionKey() calls that route `
+ `object-less and dispatchFlowAction omits \`object\` from the automation envelope for `
+ `it, so the params bag must not invent an object alias either (#14864).`,
).not.toContain(aliasKeyFor(key));
},
);

it('every object-less spelling lands the SAME bag as the canonical key', () => {
// The agreement stated as one assertion: which object-less spelling a
// caller routed at must not be observable in the flow's params.
const canonical = seedFor(GLOBAL_ACTION_OBJECT_KEY);
for (const key of OBJECT_LESS_KEYS) {
expect(seedFor(key), `routing at ${JSON.stringify(key)} produced a different params bag`)
.toEqual(canonical);
}
});
});
14 changes: 13 additions & 1 deletion packages/runtime/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -150,8 +150,20 @@ describe('standalone-action owner key — half C: no bare literal (#14678)', ()
// exactly the wrong reason — the can-never-fail property this whole
// file was written to replace. Both controls are positive assertions
// against text the converged file must carry.
//
// [#14864] The second control used to be the `seedFlowActionParams`
// comparison `objectName !== GLOBAL_ACTION_OBJECT_KEY`. That guard is
// gone — it was one of the two rival answers to "is this route
// object-less", and it now delegates to `isObjectLessActionKey` like
// its neighbours. Re-anchored rather than deleted, and deliberately
// onto a site this file's own subject does not move: the warn-once log
// key in `enforceActionParams`, which is the SECOND of the three bare
// literals #14678 converged and is untouched by the predicate work.
// ⛔ Do not re-anchor a control onto the thing the next change is most
// likely to edit — a control that moves with its subject stops being a
// control.
expect(src).toContain('GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('objectName !== GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('where.objectName ?? GLOBAL_ACTION_OBJECT_KEY');

for (const literal of BARE_LITERALS) {
expect(
Expand Down
102 changes: 102 additions & 0 deletions packages/runtime/src/action-params-enforcement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `enforceActionParams` — the ADR-0104 D2 gate itself, not its validator
* (#14864).
*
* ## What was measured, and why this file exists
*
* The card that produced this file claimed neither `seedFlowActionParams` nor
* `enforceActionParams` was named by any test. Grep cannot settle that — a pin
* can live in a file that never names the function — so both were ABLATED
* instead, repo-wide against `packages/runtime`'s 217 files / 3143 tests:
*
* - `seedFlowActionParams`, gutted → **5 tests red** in
* `http-dispatcher.actions-type-dispatch.test.ts`. Pinned all along,
* indirectly, through the REST route. The claim was wrong about it.
* - `enforceActionParams`, replaced with an unconditional `return null` (the
* gate accepting every bag) → **3143 passed, 0 failed**. Nothing in the repo
* noticed the param contract had stopped existing.
*
* The VALIDATOR is thoroughly pinned — `@objectstack/spec`'s
* `action-params.test.ts` covers `validateActionParams` case by case. What had
* no pin is the runtime GATE wrapped around it, and the gate is where the
* decisions live that the validator never makes: the param-less pass-through,
* the strict-by-default rejection, and the `OS_ALLOW_LAX_ACTION_PARAMS` escape
* hatch. A green validator says nothing about whether anything still calls it.
*
* That gap matters more than a missing unit test usually does: this gate is
* what stops an AI/MCP caller's plausible-but-wrong bag from reaching an
* action body (#3438), and its only other mention outside the source is a
* MANUALLY-run platform-checklist clause. So it is pinned here at the level
* the ablation showed to be empty.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { enforceActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `enforceActionParams` reaches nothing on `deps` — it only forwards it. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

/** No parent object schema: an object-less action carrying inline params only. */
const NO_OBJECT = undefined;

const WHERE = { objectName: 'crm_lead', actionName: 'convert_lead' };

const REQUIRES_TITLE = {
name: 'convert_lead',
params: [{ name: 'title', type: 'text', required: true }],
};

describe('enforceActionParams — the ADR-0104 D2 gate (#14864)', () => {
beforeEach(() => {
vi.unstubAllEnvs();
});

afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('anti-vacuity control: a CONFORMING bag against declared params is accepted', () => {
// Positive control for the rejection below. Without it, a gate that
// rejected everything, or one that had stopped resolving params at
// all, would still satisfy "rejects a bad bag".
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, { title: 'Hi' }, WHERE)).toBeNull();
});

it('rejects a bag that violates the declared contract, naming the param', () => {
const error = enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE);
expect(error).toContain('Invalid action params');
expect(error).toContain('title');
});

it('passes an action that declares NO params straight through', () => {
// The documented compatibility leg: nothing to validate against, so a
// param-less action is untouched however odd its bag looks.
expect(enforceActionParams(NO_DEPS, { name: 'ping' }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
expect(enforceActionParams(NO_DEPS, { name: 'ping', params: [] }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
});

it('is STRICT by default — no environment variable needed to reject (#3438)', () => {
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '');
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE)).toContain('Invalid action params');
});

it('`OS_ALLOW_LAX_ACTION_PARAMS=1` accepts the same bag instead, and warns', () => {
// The opt-OUT of a check that ships ON (Prime Directive #9): the flag
// must change the ANSWER, not merely the log line — a flag that only
// logs would leave the rejection in place and strand the caller it was
// added to unblock.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '1');

// A dedup key this suite has not warned on yet — `warnActionParamsOnce`
// keys on `objectName/actionName` and its Set is module-global, so a
// reused key would make the warn assertion pass or fail on test order.
const where = { objectName: 'crm_lead', actionName: 'lax_probe' };
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, where)).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0])).toContain('OS_ALLOW_LAX_ACTION_PARAMS=1');
});
});
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
47 changes: 47 additions & 0 deletions .changeset/object-less-action-key-one-predicate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/runtime": patch
---

fix(runtime): route the flow param seeder through the single object-less predicate (#14864)

`isObjectLessActionKey` (`@objectstack/objectql`) is the canonical answer to
"is this routed object the object-less placeholder": the canonical
`GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
`dispatchFlowAction` asks it directly when it decides whether to hand the
automation service an `object` at all — and then, three lines later, handed the
same `objectName` to `seedFlowActionParams`, which answered the same question
with a second, narrower comparison of its own (`objectName !==
GLOBAL_ACTION_OBJECT_KEY`).

The two parted on exactly one input, `'*'`. A request routed at the legacy
wildcard — `POST /actions/*/<action>/<id>`, which resolves today because
`actionHandlerObjectKeys` deliberately probes `'*'` last so a handler user code
registered against it still resolves — was object-less to the automation
envelope (no `object` sent) and object-BOUND to the params bag, which seeded a
nonsense `'*Id'` key beside `recordId`. Same dispatch, two answers.

The empty-string half was never part of the divergence: the `objectName &&`
truthiness leg of the old guard already covered it, and `undefined` with it.
`'*'` was the whole of it.

**Direction.** The guard is widened onto the shared predicate rather than
`isObjectLessActionKey` being narrowed. `'*'` is *unused today*, not *dead*:
nothing first-party registers under it, but it is a deliberately-honoured
legacy read path with its own docblock, reachable through the public
`engine.registerAction(objectName, …)` surface that user code calls. Retiring
it is a compatibility decision about someone else's package, not a tidy-up this
fix is entitled to make.

**Coverage.** Both functions this touches were ablated repo-wide first rather
than grepped, because a grep scoped to the file you expect a pin in cannot see
a pin living elsewhere:

- `seedFlowActionParams` gutted → 5 tests red. It was pinned all along,
indirectly, through the REST route — but every case there routes at a real
object, so the object-LESS leg, where the two predicates actually disagreed,
was the unpinned part. Now pinned, over the whole predicate domain.
- `enforceActionParams` replaced with an unconditional `return null` → 3143
passed, 0 failed. The ADR-0104 D2 gate could stop existing with nothing in
the repo noticing. Its validator is well pinned in `@objectstack/spec`; the
runtime gate around it was not, and that gate is what keeps an AI/MCP
caller's plausible-but-wrong bag out of an action body. Now pinned.
11 changes: 10 additions & 1 deletion packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -638,7 +638,16 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps,

if (rowId != null) {
const keys = new Set<string>(['recordId']);
if (objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY) {
// [#14864] ONE predicate for "object-less", the same one
// `dispatchFlowAction` asks three lines from here before it decides
// whether to hand the automation service an `object` at all. This used
// to be a second, narrower comparison (`objectName !==
// GLOBAL_ACTION_OBJECT_KEY`), and the two parted on exactly one input:
// a route resolved at the legacy `'*'` was object-less to the envelope
// and object-BOUND here, so the bag grew a nonsense `'*Id'` alias. The
// empty-string leg was never the divergence — the `objectName &&`
// truthiness test this replaces already covered it.
if (!isObjectLessActionKey(objectName)) {
keys.add(`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`);
}
if (typeof action?.recordIdParam === 'string' && action.recordIdParam) {
Expand Down
119 changes: 119 additions & 0 deletions packages/runtime/src/action-object-less-key-agreement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* "Object-less" has ONE answer inside `action-execution.ts` (#14864).
*
* `isObjectLessActionKey` (`@objectstack/objectql`) is the canonical predicate:
* the routed object is object-less when it is the canonical
* `GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
* `dispatchFlowAction` asks it directly when it decides whether to hand the
* automation service an `object` at all — and then, on the very next line,
* hands the same `objectName` to `seedFlowActionParams`, which used to answer
* the same question with a second, narrower comparison of its own.
*
* The two parted on exactly one input, `'*'`: the automation envelope treated a
* `'*'` route as object-less and omitted `object`, while the params bag treated
* it as a real object and seeded a nonsense `'*Id'` alias key beside
* `recordId`. One dispatch, two answers, three lines apart.
*
* ## Why the pin sits HERE and not only on the route
*
* `seedFlowActionParams` was NOT unpinned — `http-dispatcher.actions-type-
* dispatch.test.ts` covers its whole seeding ladder, indirectly, through the
* REST route, without ever naming it. What that file never does is route at an
* object-LESS key: every case there is `/crm_lead/...`. So the ladder was
* pinned and the object-less leg of it was not, which is why the divergence
* survived. This file pins the leg, at the level the two predicates actually
* meet: one function, the whole `isObjectLessActionKey` domain, one bag.
*
* ## The arms, and which one is the control
*
* The `OBJECT_FUL` case is an ANTI-VACUITY CONTROL, not a pin: it asserts the
* alias key IS seeded for a real object. If a future edit makes
* `seedFlowActionParams` seed nothing at all, the negative assertions below
* would all pass for the wrong reason, and this control is what fails instead.
* ⛔ A red here is not this file's finding — read the object-less arm first.
*/

import { describe, it, expect } from 'vitest';
import { GLOBAL_ACTION_OBJECT_KEY, isObjectLessActionKey } from '@objectstack/objectql';
import { seedFlowActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `seedFlowActionParams` ignores its first parameter — see its signature. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

const ROW_ID = 'row_1';

/**
* The `<objectName>Id` camelCase alias `seedFlowActionParams` seeds for an
* object-bound route, derived the way the function derives it rather than
* hard-coded — a hard-coded copy would go stale in silence the day the
* spelling changes, which is the same failure this whole card is about.
*/
const aliasKeyFor = (objectName: string): string =>
`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`;

const seedFor = (objectName: string): Record<string, unknown> =>
seedFlowActionParams(NO_DEPS, { name: 'convert_lead', type: 'flow' }, {
objectName,
record: {},
params: {},
recordId: ROW_ID,
});

/**
* Every string spelling `isObjectLessActionKey` accepts. The table is asserted
* against the predicate itself below, so narrowing the predicate (retiring
* `'*'`, say) fails HERE with a readable message instead of quietly leaving a
* row that no longer describes anything.
*/
const OBJECT_LESS_KEYS: readonly string[] = [GLOBAL_ACTION_OBJECT_KEY, '*', ''];

/** A real object — the control's route, and the one the REST pin already uses. */
const OBJECT_FUL = 'crm_lead';

describe('object-less action key — one predicate, one answer (#14864)', () => {
it('anti-vacuity control: an object-BOUND route still seeds its alias key', () => {
// Positive control. Every negative below is a claim that a key is
// absent; without this, deleting the seeding branch outright would
// turn them all green.
expect(isObjectLessActionKey(OBJECT_FUL)).toBe(false);
const bag = seedFor(OBJECT_FUL);
expect(bag[aliasKeyFor(OBJECT_FUL)]).toBe(ROW_ID);
expect(bag.recordId).toBe(ROW_ID);
});

it('the table below describes exactly what the predicate accepts', () => {
// Guards the table, not the code: a narrowed predicate must come here
// and say so rather than leaving an inert row behind.
for (const key of OBJECT_LESS_KEYS) {
expect(isObjectLessActionKey(key), `${JSON.stringify(key)} is no longer object-less`).toBe(true);
}
});

it.each(OBJECT_LESS_KEYS.map((key) => ({ key, label: JSON.stringify(key) })))(
'seeds no object alias for the object-less key $label',
({ key }) => {
const bag = seedFor(key);
// The row id still reaches the flow — this is about the ALIAS only.
expect(bag.recordId).toBe(ROW_ID);
expect(
Object.keys(bag),
`seedFlowActionParams seeded the alias key ${JSON.stringify(aliasKeyFor(key))} for the `
+ `object-less route ${JSON.stringify(key)}. isObjectLessActionKey() calls that route `
+ `object-less and dispatchFlowAction omits \`object\` from the automation envelope for `
+ `it, so the params bag must not invent an object alias either (#14864).`,
).not.toContain(aliasKeyFor(key));
},
);

it('every object-less spelling lands the SAME bag as the canonical key', () => {
// The agreement stated as one assertion: which object-less spelling a
// caller routed at must not be observable in the flow's params.
const canonical = seedFor(GLOBAL_ACTION_OBJECT_KEY);
for (const key of OBJECT_LESS_KEYS) {
expect(seedFor(key), `routing at ${JSON.stringify(key)} produced a different params bag`)
.toEqual(canonical);
}
});
});
14 changes: 13 additions & 1 deletion packages/runtime/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -150,8 +150,20 @@ describe('standalone-action owner key — half C: no bare literal (#14678)', ()
// exactly the wrong reason — the can-never-fail property this whole
// file was written to replace. Both controls are positive assertions
// against text the converged file must carry.
//
// [#14864] The second control used to be the `seedFlowActionParams`
// comparison `objectName !== GLOBAL_ACTION_OBJECT_KEY`. That guard is
// gone — it was one of the two rival answers to "is this route
// object-less", and it now delegates to `isObjectLessActionKey` like
// its neighbours. Re-anchored rather than deleted, and deliberately
// onto a site this file's own subject does not move: the warn-once log
// key in `enforceActionParams`, which is the SECOND of the three bare
// literals #14678 converged and is untouched by the predicate work.
// ⛔ Do not re-anchor a control onto the thing the next change is most
// likely to edit — a control that moves with its subject stops being a
// control.
expect(src).toContain('GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('objectName !== GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('where.objectName ?? GLOBAL_ACTION_OBJECT_KEY');

for (const literal of BARE_LITERALS) {
expect(
Expand Down
102 changes: 102 additions & 0 deletions packages/runtime/src/action-params-enforcement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `enforceActionParams` — the ADR-0104 D2 gate itself, not its validator
* (#14864).
*
* ## What was measured, and why this file exists
*
* The card that produced this file claimed neither `seedFlowActionParams` nor
* `enforceActionParams` was named by any test. Grep cannot settle that — a pin
* can live in a file that never names the function — so both were ABLATED
* instead, repo-wide against `packages/runtime`'s 217 files / 3143 tests:
*
* - `seedFlowActionParams`, gutted → **5 tests red** in
* `http-dispatcher.actions-type-dispatch.test.ts`. Pinned all along,
* indirectly, through the REST route. The claim was wrong about it.
* - `enforceActionParams`, replaced with an unconditional `return null` (the
* gate accepting every bag) → **3143 passed, 0 failed**. Nothing in the repo
* noticed the param contract had stopped existing.
*
* The VALIDATOR is thoroughly pinned — `@objectstack/spec`'s
* `action-params.test.ts` covers `validateActionParams` case by case. What had
* no pin is the runtime GATE wrapped around it, and the gate is where the
* decisions live that the validator never makes: the param-less pass-through,
* the strict-by-default rejection, and the `OS_ALLOW_LAX_ACTION_PARAMS` escape
* hatch. A green validator says nothing about whether anything still calls it.
*
* That gap matters more than a missing unit test usually does: this gate is
* what stops an AI/MCP caller's plausible-but-wrong bag from reaching an
* action body (#3438), and its only other mention outside the source is a
* MANUALLY-run platform-checklist clause. So it is pinned here at the level
* the ablation showed to be empty.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { enforceActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `enforceActionParams` reaches nothing on `deps` — it only forwards it. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

/** No parent object schema: an object-less action carrying inline params only. */
const NO_OBJECT = undefined;

const WHERE = { objectName: 'crm_lead', actionName: 'convert_lead' };

const REQUIRES_TITLE = {
name: 'convert_lead',
params: [{ name: 'title', type: 'text', required: true }],
};

describe('enforceActionParams — the ADR-0104 D2 gate (#14864)', () => {
beforeEach(() => {
vi.unstubAllEnvs();
});

afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('anti-vacuity control: a CONFORMING bag against declared params is accepted', () => {
// Positive control for the rejection below. Without it, a gate that
// rejected everything, or one that had stopped resolving params at
// all, would still satisfy "rejects a bad bag".
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, { title: 'Hi' }, WHERE)).toBeNull();
});

it('rejects a bag that violates the declared contract, naming the param', () => {
const error = enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE);
expect(error).toContain('Invalid action params');
expect(error).toContain('title');
});

it('passes an action that declares NO params straight through', () => {
// The documented compatibility leg: nothing to validate against, so a
// param-less action is untouched however odd its bag looks.
expect(enforceActionParams(NO_DEPS, { name: 'ping' }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
expect(enforceActionParams(NO_DEPS, { name: 'ping', params: [] }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
});

it('is STRICT by default — no environment variable needed to reject (#3438)', () => {
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '');
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE)).toContain('Invalid action params');
});

it('`OS_ALLOW_LAX_ACTION_PARAMS=1` accepts the same bag instead, and warns', () => {
// The opt-OUT of a check that ships ON (Prime Directive #9): the flag
// must change the ANSWER, not merely the log line — a flag that only
// logs would leave the rejection in place and strand the caller it was
// added to unblock.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '1');

// A dedup key this suite has not warned on yet — `warnActionParamsOnce`
// keys on `objectName/actionName` and its Set is module-global, so a
// reused key would make the warn assertion pass or fail on test order.
const where = { objectName: 'crm_lead', actionName: 'lax_probe' };
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, where)).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0])).toContain('OS_ALLOW_LAX_ACTION_PARAMS=1');
});
});
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
47 changes: 47 additions & 0 deletions .changeset/object-less-action-key-one-predicate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/runtime": patch
---

fix(runtime): route the flow param seeder through the single object-less predicate (#14864)

`isObjectLessActionKey` (`@objectstack/objectql`) is the canonical answer to
"is this routed object the object-less placeholder": the canonical
`GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
`dispatchFlowAction` asks it directly when it decides whether to hand the
automation service an `object` at all — and then, three lines later, handed the
same `objectName` to `seedFlowActionParams`, which answered the same question
with a second, narrower comparison of its own (`objectName !==
GLOBAL_ACTION_OBJECT_KEY`).

The two parted on exactly one input, `'*'`. A request routed at the legacy
wildcard — `POST /actions/*/<action>/<id>`, which resolves today because
`actionHandlerObjectKeys` deliberately probes `'*'` last so a handler user code
registered against it still resolves — was object-less to the automation
envelope (no `object` sent) and object-BOUND to the params bag, which seeded a
nonsense `'*Id'` key beside `recordId`. Same dispatch, two answers.

The empty-string half was never part of the divergence: the `objectName &&`
truthiness leg of the old guard already covered it, and `undefined` with it.
`'*'` was the whole of it.

**Direction.** The guard is widened onto the shared predicate rather than
`isObjectLessActionKey` being narrowed. `'*'` is *unused today*, not *dead*:
nothing first-party registers under it, but it is a deliberately-honoured
legacy read path with its own docblock, reachable through the public
`engine.registerAction(objectName, …)` surface that user code calls. Retiring
it is a compatibility decision about someone else's package, not a tidy-up this
fix is entitled to make.

**Coverage.** Both functions this touches were ablated repo-wide first rather
than grepped, because a grep scoped to the file you expect a pin in cannot see
a pin living elsewhere:

- `seedFlowActionParams` gutted → 5 tests red. It was pinned all along,
indirectly, through the REST route — but every case there routes at a real
object, so the object-LESS leg, where the two predicates actually disagreed,
was the unpinned part. Now pinned, over the whole predicate domain.
- `enforceActionParams` replaced with an unconditional `return null` → 3143
passed, 0 failed. The ADR-0104 D2 gate could stop existing with nothing in
the repo noticing. Its validator is well pinned in `@objectstack/spec`; the
runtime gate around it was not, and that gate is what keeps an AI/MCP
caller's plausible-but-wrong bag out of an action body. Now pinned.
11 changes: 10 additions & 1 deletion packages/runtime/src/action-execution.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -638,7 +638,16 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps,

if (rowId != null) {
const keys = new Set<string>(['recordId']);
if (objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY) {
// [#14864] ONE predicate for "object-less", the same one
// `dispatchFlowAction` asks three lines from here before it decides
// whether to hand the automation service an `object` at all. This used
// to be a second, narrower comparison (`objectName !==
// GLOBAL_ACTION_OBJECT_KEY`), and the two parted on exactly one input:
// a route resolved at the legacy `'*'` was object-less to the envelope
// and object-BOUND here, so the bag grew a nonsense `'*Id'` alias. The
// empty-string leg was never the divergence — the `objectName &&`
// truthiness test this replaces already covered it.
if (!isObjectLessActionKey(objectName)) {
keys.add(`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`);
}
if (typeof action?.recordIdParam === 'string' && action.recordIdParam) {
Expand Down
119 changes: 119 additions & 0 deletions packages/runtime/src/action-object-less-key-agreement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* "Object-less" has ONE answer inside `action-execution.ts` (#14864).
*
* `isObjectLessActionKey` (`@objectstack/objectql`) is the canonical predicate:
* the routed object is object-less when it is the canonical
* `GLOBAL_ACTION_OBJECT_KEY`, the legacy `'*'`, or nothing at all.
* `dispatchFlowAction` asks it directly when it decides whether to hand the
* automation service an `object` at all — and then, on the very next line,
* hands the same `objectName` to `seedFlowActionParams`, which used to answer
* the same question with a second, narrower comparison of its own.
*
* The two parted on exactly one input, `'*'`: the automation envelope treated a
* `'*'` route as object-less and omitted `object`, while the params bag treated
* it as a real object and seeded a nonsense `'*Id'` alias key beside
* `recordId`. One dispatch, two answers, three lines apart.
*
* ## Why the pin sits HERE and not only on the route
*
* `seedFlowActionParams` was NOT unpinned — `http-dispatcher.actions-type-
* dispatch.test.ts` covers its whole seeding ladder, indirectly, through the
* REST route, without ever naming it. What that file never does is route at an
* object-LESS key: every case there is `/crm_lead/...`. So the ladder was
* pinned and the object-less leg of it was not, which is why the divergence
* survived. This file pins the leg, at the level the two predicates actually
* meet: one function, the whole `isObjectLessActionKey` domain, one bag.
*
* ## The arms, and which one is the control
*
* The `OBJECT_FUL` case is an ANTI-VACUITY CONTROL, not a pin: it asserts the
* alias key IS seeded for a real object. If a future edit makes
* `seedFlowActionParams` seed nothing at all, the negative assertions below
* would all pass for the wrong reason, and this control is what fails instead.
* ⛔ A red here is not this file's finding — read the object-less arm first.
*/

import { describe, it, expect } from 'vitest';
import { GLOBAL_ACTION_OBJECT_KEY, isObjectLessActionKey } from '@objectstack/objectql';
import { seedFlowActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `seedFlowActionParams` ignores its first parameter — see its signature. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

const ROW_ID = 'row_1';

/**
* The `<objectName>Id` camelCase alias `seedFlowActionParams` seeds for an
* object-bound route, derived the way the function derives it rather than
* hard-coded — a hard-coded copy would go stale in silence the day the
* spelling changes, which is the same failure this whole card is about.
*/
const aliasKeyFor = (objectName: string): string =>
`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`;

const seedFor = (objectName: string): Record<string, unknown> =>
seedFlowActionParams(NO_DEPS, { name: 'convert_lead', type: 'flow' }, {
objectName,
record: {},
params: {},
recordId: ROW_ID,
});

/**
* Every string spelling `isObjectLessActionKey` accepts. The table is asserted
* against the predicate itself below, so narrowing the predicate (retiring
* `'*'`, say) fails HERE with a readable message instead of quietly leaving a
* row that no longer describes anything.
*/
const OBJECT_LESS_KEYS: readonly string[] = [GLOBAL_ACTION_OBJECT_KEY, '*', ''];

/** A real object — the control's route, and the one the REST pin already uses. */
const OBJECT_FUL = 'crm_lead';

describe('object-less action key — one predicate, one answer (#14864)', () => {
it('anti-vacuity control: an object-BOUND route still seeds its alias key', () => {
// Positive control. Every negative below is a claim that a key is
// absent; without this, deleting the seeding branch outright would
// turn them all green.
expect(isObjectLessActionKey(OBJECT_FUL)).toBe(false);
const bag = seedFor(OBJECT_FUL);
expect(bag[aliasKeyFor(OBJECT_FUL)]).toBe(ROW_ID);
expect(bag.recordId).toBe(ROW_ID);
});

it('the table below describes exactly what the predicate accepts', () => {
// Guards the table, not the code: a narrowed predicate must come here
// and say so rather than leaving an inert row behind.
for (const key of OBJECT_LESS_KEYS) {
expect(isObjectLessActionKey(key), `${JSON.stringify(key)} is no longer object-less`).toBe(true);
}
});

it.each(OBJECT_LESS_KEYS.map((key) => ({ key, label: JSON.stringify(key) })))(
'seeds no object alias for the object-less key $label',
({ key }) => {
const bag = seedFor(key);
// The row id still reaches the flow — this is about the ALIAS only.
expect(bag.recordId).toBe(ROW_ID);
expect(
Object.keys(bag),
`seedFlowActionParams seeded the alias key ${JSON.stringify(aliasKeyFor(key))} for the `
+ `object-less route ${JSON.stringify(key)}. isObjectLessActionKey() calls that route `
+ `object-less and dispatchFlowAction omits \`object\` from the automation envelope for `
+ `it, so the params bag must not invent an object alias either (#14864).`,
).not.toContain(aliasKeyFor(key));
},
);

it('every object-less spelling lands the SAME bag as the canonical key', () => {
// The agreement stated as one assertion: which object-less spelling a
// caller routed at must not be observable in the flow's params.
const canonical = seedFor(GLOBAL_ACTION_OBJECT_KEY);
for (const key of OBJECT_LESS_KEYS) {
expect(seedFor(key), `routing at ${JSON.stringify(key)} produced a different params bag`)
.toEqual(canonical);
}
});
});
14 changes: 13 additions & 1 deletion packages/runtime/src/action-owner-key-single-source.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -150,8 +150,20 @@ describe('standalone-action owner key — half C: no bare literal (#14678)', ()
// exactly the wrong reason — the can-never-fail property this whole
// file was written to replace. Both controls are positive assertions
// against text the converged file must carry.
//
// [#14864] The second control used to be the `seedFlowActionParams`
// comparison `objectName !== GLOBAL_ACTION_OBJECT_KEY`. That guard is
// gone — it was one of the two rival answers to "is this route
// object-less", and it now delegates to `isObjectLessActionKey` like
// its neighbours. Re-anchored rather than deleted, and deliberately
// onto a site this file's own subject does not move: the warn-once log
// key in `enforceActionParams`, which is the SECOND of the three bare
// literals #14678 converged and is untouched by the predicate work.
// ⛔ Do not re-anchor a control onto the thing the next change is most
// likely to edit — a control that moves with its subject stops being a
// control.
expect(src).toContain('GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('objectName !== GLOBAL_ACTION_OBJECT_KEY');
expect(src).toContain('where.objectName ?? GLOBAL_ACTION_OBJECT_KEY');

for (const literal of BARE_LITERALS) {
expect(
Expand Down
102 changes: 102 additions & 0 deletions packages/runtime/src/action-params-enforcement.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `enforceActionParams` — the ADR-0104 D2 gate itself, not its validator
* (#14864).
*
* ## What was measured, and why this file exists
*
* The card that produced this file claimed neither `seedFlowActionParams` nor
* `enforceActionParams` was named by any test. Grep cannot settle that — a pin
* can live in a file that never names the function — so both were ABLATED
* instead, repo-wide against `packages/runtime`'s 217 files / 3143 tests:
*
* - `seedFlowActionParams`, gutted → **5 tests red** in
* `http-dispatcher.actions-type-dispatch.test.ts`. Pinned all along,
* indirectly, through the REST route. The claim was wrong about it.
* - `enforceActionParams`, replaced with an unconditional `return null` (the
* gate accepting every bag) → **3143 passed, 0 failed**. Nothing in the repo
* noticed the param contract had stopped existing.
*
* The VALIDATOR is thoroughly pinned — `@objectstack/spec`'s
* `action-params.test.ts` covers `validateActionParams` case by case. What had
* no pin is the runtime GATE wrapped around it, and the gate is where the
* decisions live that the validator never makes: the param-less pass-through,
* the strict-by-default rejection, and the `OS_ALLOW_LAX_ACTION_PARAMS` escape
* hatch. A green validator says nothing about whether anything still calls it.
*
* That gap matters more than a missing unit test usually does: this gate is
* what stops an AI/MCP caller's plausible-but-wrong bag from reaching an
* action body (#3438), and its only other mention outside the source is a
* MANUALLY-run platform-checklist clause. So it is pinned here at the level
* the ablation showed to be empty.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { enforceActionParams, type ActionExecutionDeps } from './action-execution.js';

/** `enforceActionParams` reaches nothing on `deps` — it only forwards it. */
const NO_DEPS = undefined as unknown as ActionExecutionDeps;

/** No parent object schema: an object-less action carrying inline params only. */
const NO_OBJECT = undefined;

const WHERE = { objectName: 'crm_lead', actionName: 'convert_lead' };

const REQUIRES_TITLE = {
name: 'convert_lead',
params: [{ name: 'title', type: 'text', required: true }],
};

describe('enforceActionParams — the ADR-0104 D2 gate (#14864)', () => {
beforeEach(() => {
vi.unstubAllEnvs();
});

afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('anti-vacuity control: a CONFORMING bag against declared params is accepted', () => {
// Positive control for the rejection below. Without it, a gate that
// rejected everything, or one that had stopped resolving params at
// all, would still satisfy "rejects a bad bag".
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, { title: 'Hi' }, WHERE)).toBeNull();
});

it('rejects a bag that violates the declared contract, naming the param', () => {
const error = enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE);
expect(error).toContain('Invalid action params');
expect(error).toContain('title');
});

it('passes an action that declares NO params straight through', () => {
// The documented compatibility leg: nothing to validate against, so a
// param-less action is untouched however odd its bag looks.
expect(enforceActionParams(NO_DEPS, { name: 'ping' }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
expect(enforceActionParams(NO_DEPS, { name: 'ping', params: [] }, NO_OBJECT, { anything: 1 }, WHERE)).toBeNull();
});

it('is STRICT by default — no environment variable needed to reject (#3438)', () => {
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '');
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, WHERE)).toContain('Invalid action params');
});

it('`OS_ALLOW_LAX_ACTION_PARAMS=1` accepts the same bag instead, and warns', () => {
// The opt-OUT of a check that ships ON (Prime Directive #9): the flag
// must change the ANSWER, not merely the log line — a flag that only
// logs would leave the rejection in place and strand the caller it was
// added to unblock.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubEnv('OS_ALLOW_LAX_ACTION_PARAMS', '1');

// A dedup key this suite has not warned on yet — `warnActionParamsOnce`
// keys on `objectName/actionName` and its Set is module-global, so a
// reused key would make the warn assertion pass or fail on test order.
const where = { objectName: 'crm_lead', actionName: 'lax_probe' };
expect(enforceActionParams(NO_DEPS, REQUIRES_TITLE, NO_OBJECT, {}, where)).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0])).toContain('OS_ALLOW_LAX_ACTION_PARAMS=1');
});
});
Loading