Skip to content

Commit 30dcd8a

Browse files
fix(runtime): a per-record action called with no recordId is refused 400 naming it, and an unknown id 404s at the door (#29976)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB7J3TmFTYMYQrfpye6GQNg9)_ Fixes #29881 ## 维护者速读 - **改了什么:** 声明 `execution: 'perRecord'` 的动作(api 型或 flow 型)在两个动作门(REST `/actions` 与 MCP `run_action`)缺 `recordId`(或为空串)时,入流程/转发前即答 `400 VALIDATION_ERROR` 指名 `recordId`;同一 api 动作带一个调用者解析不到的 id 时,答门自己的 `404 RECORD_NOT_FOUND`,不再转发。 - **为什么改:** showcase 的 `showcase_recalc_estimate` 是 `type: 'api'` 指向 flow 端点(卡片以为是 flow 型动作,实为 api 型);缺 id 或 id 不存在时都转发进流程,拿回 `400 FLOW_FAILED` 和内部节点名 `load_task`。#28932 只按 `target` 里的 `{id}` 令牌判,这里 target 无令牌,所以改读动作自己的 `execution` 声明。 - **风险与回滚:** 被拒的调用在 `main` 上本来就失败(同 #29005 的判法,`Clause-②: no`);`aggregate` 与未声明 `execution` 的动作照旧无 id 运行(#29173),审批动作未声明 `execution`,#28657 的转发不动。`@objectstack/runtime` patch。回滚 = revert 本 PR。 - **先看:** `packages/runtime/src/action-execution.ts` 的 `perRecordActionRecordIdRefusal` 及其两处调用(`buildApiActionRequest`、`dispatchFlowAction`),和 `forwardsOnDeniedSubjectLoad` 新增的一行。 - **两分钟验证:** `pnpm --filter @objectstack/runtime exec vitest run src/action-flow-per-record-record-id.test.ts src/domains/api-action-forward.test.ts`,预期 `92 passed`。 - **需要你做的:** 无,席位评审并落地。 Clause-②: no Metadata-protocol: none ## What changes - **`@objectstack/runtime`, `action-execution.ts`.** A new `perRecordActionRecordIdRefusal(action, recordId)` answers `{ status: 400, message }` when the action declares `execution: 'perRecord'` and no record id was given (`undefined`, `null` or `''`). It reads only the declaration and the id. The message has the #28932 shape, and names `recordId` and the declaration: ``` Action 'showcase_recalc_estimate' is declared `execution: 'perRecord'` — it runs on one record — and no `recordId` was given. Pass the id of the record to run it on as `recordId`. Nothing was run. ``` It is read in two places, and both doors reach both: - **api arm:** `buildApiActionRequest`, right beside #28932's `target`-token refusal, so nothing is forwarded; - **flow arm:** the top of `dispatchFlowAction`, thrown with `status: 400` and the code derived from it (`VALIDATION_ERROR`), so no run is created and no node's diagnostic reaches the caller. - **`forwardsOnDeniedSubjectLoad`** returns `false` for an action declared `execution: 'perRecord'`. A per-record api action whose record the caller's scope does not resolve (an unknown id, or a row it cannot read; the two are one answer by design) gets the door's shared `404 RECORD_NOT_FOUND` before anything runs, as a flow or script action already did. Before, it was forwarded, and the recalc flow answered `400 FLOW_FAILED` wrapping "Record … not found". - Nothing else moves. Actions declared `aggregate`, or declaring no `execution`, run record-less as before (#29173). `approval_approve` (the #28657 shape) declares no `execution` and keeps forwarding on a denied load. `action-execution.ts` is not re-exported from the package entry, so the new function adds nothing to the public surface. ## Why Card #29881: `POST /api/v1/actions/showcase_task/showcase_recalc_estimate {}` answered `400 FLOW_FAILED` with "Node 'recalc_each' failed: … Node 'load_task' failed: get_record: refusing to run — 1 filter operand(s) evaluated to nothing: `recordId`…". With an unknown id it answered the same code wrapping "Record … not found". The caller could not tell that it had left out an argument, and the flow's internal node names reached it. **Premise readings (measured at `18112bfa`):** - The card's two actions are not flow-bodied. Both are `type: 'api'` actions whose `target` (`/api/v1/apps/showcase/recalc`) is a `type: 'flow'` endpoint. #28932's check reads the `target` for a record token, and this target has none, so the call was forwarded with no id. The per-record signal the platform already has is the action's own `execution` declaration (`ActionSchema.execution`, #17319). The fix reads that for the api arm, and also for `type: 'flow'` actions, which had the same gap. - `showcase_recalc_selection` is declared `execution: 'aggregate'`. It is NOT per-record, and #29173 deliberately lets a record-less aggregate call through, so it keeps running with no id. It is this PR's "not per-record keeps running" control. What its endpoint makes of an empty selection (the same `FLOW_FAILED` with node names) belongs to that flow, not the action door, and this PR leaves it as it is. - Unknown id: #28932's own arm (`approval_approve`) does not 404 at the door. It forwards, and the approvals route answers its own `404 REQUEST_NOT_FOUND`. Matching that here would still hand the caller the flow's node names, so the per-record declaration also opts the action out of the #28657 forward-on-denied-load exception. The door's existing `404 RECORD_NOT_FOUND` envelope is used, and no new code is added. **Accept set (`Clause-②: no`, the #29005 reading).** Every call refused now already failed on `main`. With no id, the forward ran a flow that refused at its first read. With an unresolvable id, it ran a flow that could not read the row as the caller (`runAs: 'user'`). In-repo census of `execution: 'perRecord'`: four showcase actions. Two are `operation: 'update'` (that arm is untouched). Two are api actions: the recalc one, and `showcase_generate_invoice_pdf` → `/api/v1/print/records`, whose route reads the record as the caller. No plugin package declares `perRecord`. ## Verification - **Unit, `packages/runtime/src/domains/api-action-forward.test.ts`**, new `[#29881]` describe, through the real `HttpDispatcher` REST door and `invokeBusinessAction` (MCP): - with `{}`, `{ params: {} }` and `{ recordId: '' }`, the request is refused `400 VALIDATION_ERROR` naming `recordId`, and the target is never called; - an unresolvable id gets `404 RECORD_NOT_FOUND`, and nothing is forwarded; - controls: a real id is forwarded with `{ recordId }`, and the aggregate twin with no id is forwarded; - helpers: `forwardsOnDeniedSubjectLoad` is false for per-record and true for aggregate and undeclared; `buildApiActionRequest` builds no request without an id. - **Unit, `packages/runtime/src/action-flow-per-record-record-id.test.ts`** (new): a `type: 'flow'` per-record action, on both doors, is refused 400 naming `recordId`, and `automation.execute` is never called. An unknown id keeps the 404. A real id runs. Aggregate and undeclared flow actions run with no id. - **Real HTTP, `packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts`**, new `[#29881]` case on a booted stock showcase, as admin: - `showcase_recalc_estimate` with `{}` and with `{ recordId: '' }` answers 400 `VALIDATION_ERROR` naming `recordId`; the body contains neither `load_task` nor `FLOW_FAILED`; - an unknown id answers 404 `RECORD_NOT_FOUND` with no `load_task`; - a seeded task answers 200; - `showcase_recalc_selection {}` is not refused at the door. - The file has 11 tests, all passed. - **Ablations**, each run once and restored: 1. Unit: the three call sites removed (helper kept so the import resolves): 6 failed, every new negative pin, e.g. `REST: no recordId … answers 400 VALIDATION_ERROR` → `AssertionError: {}: expected 200 to be 400`, and the 404 pin → `expected 200 to be 404`. Restored: 92 passed. 2. Real HTTP: runtime rebuilt from `main`'s `action-execution.ts`. The `[#29881]` case failed with the card's body verbatim: `{"code":"FLOW_FAILED","message":"Node 'recalc_each' failed: map 'recalc_each': item 0 (subflow 'showcase_recalc_task_estimate') failed: Node 'load_task' failed: get_record: refusing to run — 1 filter operand(s) evaluated to nothing: \`recordId\` …"}: expected 'FLOW_FAILED' to be 'VALIDATION_ERROR'`. Rebuilt with the fix: 11 passed. - **Build and suites:** the `@objectstack/runtime...` closure build: exit 0. The `@objectstack/dogfood^...` closure build: exit 0. Runtime `pnpm test`: 540 files, 8614 passed / 19 skipped, exit 0. `test:repo`: 3 files, 1211 passed, exit 0. `tsc --noEmit`: exit 0. `check:test-typecheck`: exit 0. - **`pnpm lint`:** exit 0. - **`node scripts/pm/dispatch-gates.mjs --commands`** at the change set vs `origin/main` `18112bfa`: 64 derived commands, all exit 0, with two notes: - `check-plugin-teardown-shape.mjs --self-test` first exited 3 because its fixture commit `621a4876` was not in the shallow clone; after fetching it: 48 cases pass, exit 0. - `check:dual-build-cjs-loads` first exited 3 because 7 package dists were missing; after building them: exit 0 (116 entry points, 71 packages). - The 5 artifact-roster gates whose roster sits under a path this diff touches (`check:authz-resolver`, `check:changeset-fixed`, `check:error-code-casing`, `check:filter-alias-parity`, `check:route-ledger-census`): all exit 0. - `dispatch-gates --ran`: `✓ 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED`. - The gates ran on commit `63068066`. The pushed head `3648acbb` is the same tree: only the commit message was reworded, for the trailer hook. - **Serial:** no open PR found touching the action door's per-record path. The one open PR a search returned (#29970) edits only `ui/` files. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (spec untouched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the `维护者速读` section is the seat's Chinese summary for the maintainer). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzf1rWiBfPzSuFgeCcr <!-- signed: domain:server#6 · os-support-ai · session_01WGufzf1rWiBfPzSuFgeCcr --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c92b887 commit 30dcd8a

6 files changed

Lines changed: 340 additions & 2 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): a per-record action called with no recordId is refused 400 naming it, and an unknown id 404s at the door, before its flow runs
6+
7+
Clause-②: no — every call refused now already failed on `main`: the flow behind the action ran and answered `400 FLOW_FAILED` naming its internal node, with no id or with an id the caller cannot resolve.
8+
Metadata-protocol: none
9+
10+
An action declared `execution: 'perRecord'` (an api action or a flow action) called through the REST `/actions` door or MCP `run_action` with no `recordId`, or an empty one, is refused `400 VALIDATION_ERROR` naming `recordId`, and nothing is forwarded or started. The same api action called with an id the caller's scope does not resolve answers the door's own `404 RECORD_NOT_FOUND` instead of being forwarded, as a flow or script action already did. Actions declared `aggregate`, or declaring no `execution`, run record-less as before (#29173). Fixes #29881.

‎content/docs/ui/actions.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,11 @@ it. Rename the underlying function freely; as long as the declaration's
574574
A body `recordId` names one record by its id, a string or a number. Any other
575575
value is refused `400 VALIDATION_FAILED` naming `recordId`, before the declared
576576
param contract, the record load and dispatch, for every action type. An absent,
577-
`null` or empty `recordId` is the record-less call.
577+
`null` or empty `recordId` is the record-less call — except on an action declared
578+
`execution: 'perRecord'`, which runs on one record: an `api` or `flow` action
579+
declared so is refused `400 VALIDATION_ERROR` naming `recordId` before anything
580+
is forwarded or started, and one whose record does not resolve for you answers
581+
`404 RECORD_NOT_FOUND`.
578582

579583
Failures speak HTTP — the status code is the signal (#3962):
580584

‎packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@
3838
// signed-in session, so a key caller of `ban_user` is refused BY NAME at
3939
// the door (the target's anonymous "Sign in first" was the defect) and
4040
// nobody is banned, while a key caller of an api action whose target is
41-
// the data door is still forwarded and served.
41+
// the data door is still forwarded and served;
42+
// - a per-record api action (#29881): `showcase_recalc_estimate` with no
43+
// `recordId` is refused 400 naming it, an unknown id 404s at the door,
44+
// and neither answer names the recalc flow's internal node.
4245
//
4346
// `OS_SCIM_ENABLED=true` precedes the boot because `/admin/*` 501s without
4447
// better-auth's admin plugin, and that env knob is the one that reaches it
@@ -298,6 +301,37 @@ describe('[#27506] a same-origin api action is dispatched through the action doo
298301
expect(message).toContain('not exposed to AI');
299302
}, 120_000);
300303

304+
it('[#29881] the per-record recalc action names the missing `recordId` and 404s an unknown id at the door; its aggregate twin still runs', async () => {
305+
// Before: both calls ran the recalc flow and answered `400 FLOW_FAILED`
306+
// naming its internal node (`load_task`).
307+
for (const body of [{}, { recordId: '' }]) {
308+
const missing = await call({ token: admin }, 'POST', '/actions/showcase_task/showcase_recalc_estimate', body);
309+
expect(missing.status, missing.text).toBe(400);
310+
expect(missing.json?.error?.code, missing.text).toBe('VALIDATION_ERROR');
311+
expect(String(missing.json?.error?.message)).toContain('`recordId`');
312+
expect(missing.text).not.toContain('load_task');
313+
expect(missing.text).not.toContain('FLOW_FAILED');
314+
}
315+
316+
const unknown = await call({ token: admin }, 'POST', '/actions/showcase_task/showcase_recalc_estimate', { recordId: 'task-does-not-exist-29881' });
317+
expect(unknown.status, unknown.text).toBe(404);
318+
expect(unknown.json?.error?.code, unknown.text).toBe('RECORD_NOT_FOUND');
319+
expect(unknown.text).not.toContain('load_task');
320+
321+
// The control: a real task is recalculated through the same door.
322+
const [task] = rowsOf(await ql.find('showcase_task', { limit: 1 }, { context: SYS }));
323+
expect(task?.id, 'the showcase seeds no task').toBeTruthy();
324+
const ran = await call({ token: admin }, 'POST', '/actions/showcase_task/showcase_recalc_estimate', { recordId: String(task.id) });
325+
expect(ran.status, ran.text).toBe(200);
326+
327+
// Not per-record: `showcase_recalc_selection` is `execution: 'aggregate'`,
328+
// so a call with no id is not refused at the door and still reaches its
329+
// target (#29173); what the target makes of an empty selection is its own.
330+
const bulk = await call({ token: admin }, 'POST', '/actions/showcase_task/showcase_recalc_selection', {});
331+
expect(bulk.json?.error?.code, bulk.text).not.toBe('VALIDATION_ERROR');
332+
expect(bulk.text).not.toContain("`execution: 'perRecord'`");
333+
}, 120_000);
334+
301335
it('impersonate_user, cookie lane: every Set-Cookie line the target mints comes back through the door', async () => {
302336
const cookie = await adminCookie();
303337

‎packages/runtime/src/action-execution.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,6 +1379,32 @@ function unresolvedApiTargetRecordRefusal(name: string, target: string, missing:
13791379
};
13801380
}
13811381

1382+
/**
1383+
* [#29881] The refusal for an action declared `execution: 'perRecord'` called
1384+
* with no record id (an empty one is none): `undefined` when the action is not
1385+
* declared per-record, or an id was given. The declaration is the action's own
1386+
* statement that its body runs on ONE record (`ActionSchema.execution`), so a
1387+
* call naming none is refused at the door, naming `recordId`, before anything
1388+
* runs — the #28932 shape, read from the declaration instead of a `target`
1389+
* token. Without it the body ran and failed on its own: a flow endpoint
1390+
* answered `400 FLOW_FAILED` naming the flow's internal node.
1391+
*
1392+
* Read by the api arm ({@link buildApiActionRequest}) and the flow arm
1393+
* ({@link dispatchFlowAction}); both doors reach both. An `aggregate` action,
1394+
* or one that declares no `execution`, runs record-less as before (#29173).
1395+
*/
1396+
export function perRecordActionRecordIdRefusal(action: any, recordId: unknown): ApiActionRequestRefusal | undefined {
1397+
if (action?.execution !== 'perRecord') return undefined;
1398+
if (recordId !== undefined && recordId !== null && recordId !== '') return undefined;
1399+
const name: string = action?.name ?? 'unknown';
1400+
return {
1401+
status: 400,
1402+
message:
1403+
`Action '${name}' is declared \`execution: 'perRecord'\` — it runs on one record — and no \`recordId\` was given. ` +
1404+
`Pass the id of the record to run it on as \`recordId\`. Nothing was run.`,
1405+
};
1406+
}
1407+
13821408
/**
13831409
* Assemble the request a same-origin `type: 'api'` action makes — method,
13841410
* path and JSON body — from its DECLARATION and the dispatch inputs, by the
@@ -1428,6 +1454,10 @@ export function buildApiActionRequest(
14281454
// An empty id is no id: `${ctx.recordId}` and `{id}` stay unresolved, and
14291455
// are refused below by name, rather than blanked into a path naming no record.
14301456
const routedRecordId: unknown = input.recordId || (input.record as any)?.id || undefined;
1457+
// [#29881] A per-record action names its record whatever its `target`
1458+
// says: with none, nothing is forwarded.
1459+
const perRecordRefusal = perRecordActionRecordIdRefusal(action, routedRecordId);
1460+
if (perRecordRefusal) return perRecordRefusal;
14311461
let path = substituteApiTargetTokens(target, {
14321462
param: input.params,
14331463
ctx: {
@@ -2586,6 +2616,14 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps,
25862616
},
25872617
): Promise<any> {
25882618
const { objectName, subject, params, recordId, ec, envId } = wiring;
2619+
// [#29881] A per-record flow action with no record id is refused before
2620+
// the flow starts: no run exists, and no node's diagnostic reaches the caller.
2621+
const perRecordRefusal = perRecordActionRecordIdRefusal(action, recordId);
2622+
if (perRecordRefusal) {
2623+
throw Object.assign(new Error(perRecordRefusal.message), {
2624+
code: standardErrorCodeForHttpStatus(perRecordRefusal.status), status: perRecordRefusal.status,
2625+
});
2626+
}
25892627
const record = subject.record;
25902628
const automation = await resolveAutomationService(deps, requestContext, envId);
25912629
if (!automation) {
@@ -3715,10 +3753,19 @@ export function refuseDeniedSubjectLoadBeforeDispatch(
37153753
* `type: 'api'` action (the doors refuse an off-origin one earlier) whose
37163754
* request can be built without the row. A declarative update is never one,
37173755
* whatever its `type` says: `operation` before `type`.
3756+
*
3757+
* [#29881] Nor is an action declared `execution: 'perRecord'`: it says it runs
3758+
* on that record, so a record the caller's scope does not resolve (an unknown
3759+
* id, or a row it cannot read: one answer, by design) gets the door's own
3760+
* `404 RECORD_NOT_FOUND` before anything runs, as a flow or script body does,
3761+
* instead of whatever its target's body made of the missing row (a flow
3762+
* endpoint answered `400 FLOW_FAILED` naming its internal node). The #28657
3763+
* approvals shape declares no `execution`, and keeps forwarding.
37183764
*/
37193765
export function forwardsOnDeniedSubjectLoad(action: any): boolean {
37203766
return !isDeclarativeUpdateAction(action)
37213767
&& action?.type === 'api'
3768+
&& action?.execution !== 'perRecord'
37223769
&& isSameOriginApiTarget(action?.target)
37233770
&& !apiActionRequestReadsSubjectRow(action);
37243771
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29881] A `type: 'flow'` action declared `execution: 'perRecord'` called
5+
* with no record id is refused at the door — `400 VALIDATION_ERROR` naming
6+
* `recordId` — before the flow starts, on both doors (REST `/actions` and MCP
7+
* `run_action`; both reach `dispatchFlowAction`). Before, the flow ran and the
8+
* caller got the first node's diagnostic (`400 FLOW_FAILED`, naming the node).
9+
*
10+
* The controls: the same action with a real id runs; one that declares no
11+
* `execution`, or `aggregate`, runs record-less as before; an unknown id keeps
12+
* the door's `404 RECORD_NOT_FOUND` (#16370). The api-arm half of the card is
13+
* in `domains/api-action-forward.test.ts`.
14+
*/
15+
16+
import { describe, it, expect, vi } from 'vitest';
17+
18+
import { HttpDispatcher } from './http-dispatcher.js';
19+
import { callData, invokeBusinessAction, perRecordActionRecordIdRefusal } from './action-execution.js';
20+
21+
const OBJECT = 'crm_case';
22+
const RECORD_ID = 'case_1';
23+
const GHOST_ID = 'case_none';
24+
const FLOW_NAME = 'crm_case_recalc';
25+
26+
const flowAction = (name: string, extra: Record<string, unknown> = {}) => ({
27+
name, label: name, objectName: OBJECT, type: 'flow', target: FLOW_NAME,
28+
ai: { exposed: true, description: `Run ${name}.` }, ...extra,
29+
});
30+
const PER_RECORD = flowAction('recalc_case', { execution: 'perRecord' });
31+
const AGGREGATE = flowAction('recalc_cases', { execution: 'aggregate' });
32+
const UNDECLARED = flowAction('recalc_any');
33+
const OBJECT_DEF = { name: OBJECT, actions: [PER_RECORD, AGGREGATE, UNDECLARED] };
34+
35+
function harness() {
36+
const schemaOf = (n: string) => (n === OBJECT ? OBJECT_DEF : undefined);
37+
const ql: any = {
38+
executeAction: vi.fn(),
39+
getSchema: schemaOf,
40+
registry: { getObject: schemaOf, getItem: () => undefined },
41+
find: vi.fn(async (_object: string, options?: any) => {
42+
const wanted = options?.filters?.[0]?.[2] ?? options?.where?.id;
43+
return wanted === RECORD_ID ? [{ id: RECORD_ID }] : [];
44+
}),
45+
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
46+
};
47+
const automation = {
48+
execute: vi.fn(async () => ({ success: true, status: 'completed', runId: 'run_1', durationMs: 1 })),
49+
getFlow: vi.fn(async (name: string) => (name === FLOW_NAME ? { name } : null)),
50+
};
51+
const metadata: any = {
52+
load: vi.fn(async () => null),
53+
loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })),
54+
loadMany: vi.fn(async () => []),
55+
listObjects: vi.fn(async () => [OBJECT_DEF]),
56+
getObject: vi.fn(async (n: string) => (n === OBJECT ? OBJECT_DEF : undefined)),
57+
};
58+
return { ql, automation, metadata };
59+
}
60+
61+
const EC = { userId: 'usr_1', tenantId: 'org_1', positions: [], permissions: [], systemPermissions: [] };
62+
63+
async function rest(name: string, body: Record<string, unknown>) {
64+
const h = harness();
65+
const resolve = (n: string) =>
66+
n === 'objectql' || n === 'data' ? h.ql : n === 'metadata' ? h.metadata : n === 'automation' ? h.automation : null;
67+
const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } };
68+
const context: any = { request: {}, environmentId: 'platform', executionContext: EC };
69+
const res: any = await (new HttpDispatcher(kernel) as any).handleActions(`/${OBJECT}/${name}`, 'POST', body, context);
70+
return { res: res.response, automation: h.automation };
71+
}
72+
73+
async function mcp(name: string, input: Record<string, unknown>) {
74+
const h = harness();
75+
const deps: any = {
76+
resolveService: async (_ctx: any, service: string) => (service === 'automation' ? h.automation : undefined),
77+
getObjectQL: async () => h.ql,
78+
};
79+
const requestContext: any = { request: {}, environmentId: 'platform' };
80+
const outcome = await invokeBusinessAction(deps, requestContext, name, input as any, {
81+
driver: undefined, envId: 'platform', ec: EC, getMeta: () => h.metadata,
82+
callData: (a, p, d, s, e) => callData(deps, requestContext, a, p, d, s, e),
83+
}).catch((e) => e);
84+
return { outcome, automation: h.automation };
85+
}
86+
87+
const bodyText = (res: any) => JSON.stringify(res?.body ?? {});
88+
89+
describe('[#29881] a per-record flow action with no record id is refused before the flow starts', () => {
90+
it('REST: no `recordId`, or an empty one, answers 400 VALIDATION_ERROR naming it, and the flow never runs', async () => {
91+
for (const body of [{}, { params: {} }, { recordId: '' }]) {
92+
const { res, automation } = await rest(PER_RECORD.name, body);
93+
expect(res.status, JSON.stringify(body)).toBe(400);
94+
expect(res.body?.error?.code).toBe('VALIDATION_ERROR');
95+
expect(String(res.body?.error?.message)).toContain('`recordId`');
96+
expect(bodyText(res)).not.toContain('FLOW_FAILED');
97+
expect(automation.execute).not.toHaveBeenCalled();
98+
}
99+
});
100+
101+
it('MCP `run_action`: the same refusal is thrown, and the flow never runs', async () => {
102+
for (const input of [{}, { recordId: '' }]) {
103+
const { outcome, automation } = await mcp(PER_RECORD.name, input);
104+
expect(outcome?.status, JSON.stringify(input)).toBe(400);
105+
expect(outcome?.code).toBe('VALIDATION_ERROR');
106+
expect(String(outcome?.message)).toContain('`recordId`');
107+
expect(automation.execute).not.toHaveBeenCalled();
108+
}
109+
});
110+
111+
it('REST: an unknown id keeps the door\'s 404 RECORD_NOT_FOUND, and the flow never runs', async () => {
112+
const { res, automation } = await rest(PER_RECORD.name, { recordId: GHOST_ID });
113+
expect(res.status).toBe(404);
114+
expect(res.body?.error?.code).toBe('RECORD_NOT_FOUND');
115+
expect(automation.execute).not.toHaveBeenCalled();
116+
});
117+
118+
it('the controls: a real id runs the flow; an aggregate or undeclared flow action runs with no id', async () => {
119+
const real = await rest(PER_RECORD.name, { recordId: RECORD_ID });
120+
expect(real.res.status, bodyText(real.res)).toBe(200);
121+
expect(real.automation.execute).toHaveBeenCalledTimes(1);
122+
123+
for (const action of [AGGREGATE, UNDECLARED]) {
124+
const { res, automation } = await rest(action.name, {});
125+
expect(res.status, `${action.name} ${bodyText(res)}`).toBe(200);
126+
expect(automation.execute, action.name).toHaveBeenCalledTimes(1);
127+
128+
const viaMcp = await mcp(action.name, {});
129+
expect(viaMcp.outcome?.ok, action.name).toBe(true);
130+
expect(viaMcp.automation.execute, action.name).toHaveBeenCalledTimes(1);
131+
}
132+
});
133+
134+
it('perRecordActionRecordIdRefusal reads only the declaration and the id', () => {
135+
expect(perRecordActionRecordIdRefusal(PER_RECORD, undefined)?.status).toBe(400);
136+
expect(perRecordActionRecordIdRefusal(PER_RECORD, '')?.status).toBe(400);
137+
expect(perRecordActionRecordIdRefusal(PER_RECORD, null)?.status).toBe(400);
138+
expect(perRecordActionRecordIdRefusal(PER_RECORD, RECORD_ID)).toBeUndefined();
139+
expect(perRecordActionRecordIdRefusal(AGGREGATE, undefined)).toBeUndefined();
140+
expect(perRecordActionRecordIdRefusal(UNDECLARED, undefined)).toBeUndefined();
141+
expect(perRecordActionRecordIdRefusal(undefined, undefined)).toBeUndefined();
142+
});
143+
});

0 commit comments

Comments
 (0)