From e92495c0156ddd731bc3a2fcea90dd7281618c8a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:13:57 +0000 Subject: [PATCH 1/2] fix(runtime): judge flow start inputs against the object the flow reads them from A start input that a flow's own definition reads as the id of a record of a fixed object (a CRUD node's filter.id, $eq or $in, a screen edit form's recordId, handed on through subflow and map inputs) is now loaded in the caller's own scope from that object at every door that starts the flow by name, and must be one id, or a list of ids where it is read as a list. A value the caller cannot read there is refused 404 RECORD_NOT_FOUND, and any other shape 400 VALIDATION_FAILED naming the input, before the run exists. The REST action route also refuses a body recordId that is not one id with 400 naming recordId, before the param contract, the load and dispatch. Claude-Session: https://claude.ai/code/session_01ADHkmRp2YLTpRc6Vfw6TD3 Co-authored-by: Claude --- .../29050-flow-declared-record-reads.md | 32 + packages/runtime/src/action-execution.ts | 139 ++++- packages/runtime/src/domains/actions.ts | 12 + packages/runtime/src/domains/automation.ts | 7 +- packages/runtime/src/flow-start-admission.ts | 194 +++++- .../flow-start-declared-record-reads.test.ts | 576 ++++++++++++++++++ 6 files changed, 931 insertions(+), 29 deletions(-) create mode 100644 .changeset/29050-flow-declared-record-reads.md create mode 100644 packages/runtime/src/flow-start-declared-record-reads.test.ts diff --git a/.changeset/29050-flow-declared-record-reads.md b/.changeset/29050-flow-declared-record-reads.md new file mode 100644 index 00000000000..1c6b559ff7c --- /dev/null +++ b/.changeset/29050-flow-declared-record-reads.md @@ -0,0 +1,32 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): a start input a flow's definition reads as a record id is loaded in the caller's own scope from the object the flow reads it from, and must be an id, before the run exists (#29050) + +Clause-②: no +Metadata-protocol: none + +**The rule.** Where a flow's own definition reads a start input as the id of a record of a fixed object, every door that starts the flow by name loads that input in the caller's own scope from THAT object before the run exists, and refuses a value the caller cannot read there with the action door's own `404 RECORD_NOT_FOUND` (ADR-0112 envelope). A record the caller cannot read and an id that names nothing get the same answer. The input must name one record by its id (a string or a number), or a list of records by their ids where the definition reads it as a list; any other value is refused `400 VALIDATION_FAILED` naming the input. Nothing runs on a refusal: no run record, no node, no side effect. This holds whatever object the start names, or none, and whatever the flow runs as. + +**Where a definition reads an input as a record id.** Across every region of the flow (loop bodies, parallel branches, try and catch regions): + +- a `get_record`, `update_record` or `delete_record` node with a literal `objectName` whose `filter.id` or `filter.id.$eq` is the input (one id), or whose `filter.id.$in` is (a list of ids); +- a `screen` node with a literal `objectName` whose `recordId` is the input; +- a `subflow` node with a literal `flowName` that hands the input to a callee input the callee reads so, and a `map` node that does the same with the input itself or with each item of it (a `map` iterating the input reads it as a list of ids). + +"Is the input" means a CEL value whose whole source is the input's name (`caseId` or `vars.caseId`). + +**Which doors.** `POST /api/v1/automation/:name/trigger` and the legacy `POST /api/v1/automation/trigger/:name`; a declared endpoint of `type: 'flow'`; and an action of `type: 'flow'` (the REST action route and the MCP `run_action` tool), for each key of the caller's own `params` and for the routed record id under the row slots, so the routed record is judged against the object the flow reads as well as the action's own object. + +**The REST action route's routed record is one id.** A body `recordId` that is not one id (a string or a number) is refused `400 VALIDATION_FAILED` naming `recordId`, before the declared param contract, the record load and dispatch, for every action type. A path segment is always one id; an absent, `null` or empty `recordId` is the record-less call, as before. + +**What stays as it was.** + +- A start whose caller can read the record in the object the flow reads it from. +- An input the definition does not read as a record id, including a value the flow computes from an input or derives elsewhere. +- A name a `loop` or `map` node binds as its item or index variable, which no longer holds the start's value once that node runs. +- The system principal (an in-process caller, such as a job). +- At the action door, the subject record's own fields, and the row slots of an action whose `recordIdField` names a field other than `id`. + +**If a start now answers 404 or 400.** Hand the input the id of a record the caller can read in the object the flow reads it from, in the shape the flow reads (one id, or a list of ids). diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index ae49d724ea1..6338ef125d1 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -65,7 +65,7 @@ import { // Who may START a flow by name — the one rule the trigger door, this door and // the declared endpoint share (the maintainer's rulings, letters B and A, and // the flow's own `requiredPermissions`). -import { flowRunsElevated, flowStartRefusal } from './flow-start-admission.js'; +import { flowDeclaredRecordReads, flowRunsElevated, flowStartRefusal } from './flow-start-admission.js'; import { validationFailure } from './validation-failure.js'; // [ADR-0066 D4] The capability arithmetic the action gate and the flow start // gate share. @@ -2383,6 +2383,33 @@ function flowActionCallerRecordKeys( return keys; } +/** + * The inputs this door hands a run from the REQUEST, each with the value the + * run receives under it ({@link seedFlowActionParams}'s bag), as flow start + * admission's third question judges them against the object the flow's own + * definition reads them from ({@link refuseUnreadableFlowStartRecords}): every + * key of the caller's own `params`, and each row slot carrying the routed + * record id. Two kinds of key are left out, because their values come from a + * row the caller has already shown it can read, not from the request: + * + * - the subject row's own fields, which the bag carries under their names; + * - the row slots of an action whose `recordIdField` names a field other than + * `id`, which carry that field's value. + */ +function flowActionStartInputs( + action: any, + objectName: string, + params: Record, + runParams: Record, +): Array { + const keys = new Set(Object.keys(params)); + const idField: string = typeof action?.recordIdField === 'string' && action.recordIdField ? action.recordIdField : 'id'; + if (idField === 'id') { + for (const key of flowActionRowIdKeys(action, objectName)) keys.add(key); + } + return [...keys].map((key) => [key, runParams[key]] as const); +} + /** * Brand for {@link FlowActionRefusal} — `Symbol.for`, so recognition keeps * working even if two module instances of this file ever coexist (src vs @@ -2607,10 +2634,21 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps, // (the request's own driver, environment and identity, as both entrances // load the routed row), and refused with that load's 404 before the run // exists. Thrown with `code` + `status`, like the refusals above. + // + // The same question then follows every value this door hands the run's + // inputs that came from the request (the caller's params, and the routed + // id under the row slots) to the object the flow's own definition reads it + // from, where it declares one: the routed row was judged against the + // action's object, which is not always the object the flow reads. + const runParams = seedFlowActionParams(deps, action, { objectName, record, params, recordId }); await refuseUnreadableFlowStartRecords( automation, action.target, - { objectName, recordKeys: flowActionCallerRecordKeys(action, objectName, params, recordId, subject) }, + { + objectName, + recordKeys: flowActionCallerRecordKeys(action, objectName, params, recordId, subject), + inputs: flowActionStartInputs(action, objectName, params, runParams), + }, ec, (object, id) => callData(deps, requestContext, 'get', { object, id }, requestContext?.dataDriver, envId, ec), ); @@ -2640,7 +2678,7 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps, // twin (`buildAutomationContext`): a refused run's message renders in // it. See `AutomationContext.locale`. ...(typeof ec?.locale === 'string' && ec.locale.length > 0 ? { locale: ec.locale } : {}), - params: seedFlowActionParams(deps, action, { objectName, record, params, recordId }), + params: runParams, // [#19846] Which of those keys the CALLER supplied, stated here where it // is known rather than inferred downstream from the merged bag. callerParamKeys: flowActionCallerParamKeys(action, objectName, params), @@ -3733,13 +3771,30 @@ export type FlowStartRecordKeys = ReadonlyArray Promise, ): Promise { if (executionContext?.isSystem === true) return; - const named = start.recordKeys.filter(([, value]) => value !== undefined && value !== null && value !== ''); - if (named.length === 0) return; - const objectName = typeof start.objectName === 'string' && start.objectName !== '' && !isObjectLessActionKey(start.objectName) - ? start.objectName - : undefined; - if (objectName === undefined) { - if (!(await flowRunsElevated(automation, flowName))) return; - const message = 'A start that names a record must also name the record\'s object (`objectName`), ' - + 'so the caller\'s access to that record can be checked before the flow starts.'; - throw validationFailure(message, [{ field: 'objectName', code: 'required', message }]); - } const loaded = new Set(); - for (const [key, value] of named) { - if (typeof value !== 'string' && typeof value !== 'number') { - const message = `The record key \`${key}\` must name one record by its id.`; - throw validationFailure(message, [{ field: key, code: 'invalid_type', message }]); - } - const recordId = String(value); - if (loaded.has(recordId)) continue; + const loadOrRefuse = async (objectName: string, recordId: string): Promise => { + const once = `${objectName}\u0000${recordId}`; + if (loaded.has(once)) return; const subject = await loadActionSubjectRecord(objectName, recordId, () => readRecord(objectName, recordId)); refuseDeniedSubjectLoad(objectName, recordId, subject); - loaded.add(recordId); + loaded.add(once); + }; + const named = start.recordKeys.filter(([, value]) => isNamedStartValue(value)); + if (named.length > 0) { + const objectName = typeof start.objectName === 'string' && start.objectName !== '' && !isObjectLessActionKey(start.objectName) + ? start.objectName + : undefined; + if (objectName === undefined) { + if (await flowRunsElevated(automation, flowName)) { + const message = 'A start that names a record must also name the record\'s object (`objectName`), ' + + 'so the caller\'s access to that record can be checked before the flow starts.'; + throw validationFailure(message, [{ field: 'objectName', code: 'required', message }]); + } + } else { + for (const [key, value] of named) { + await loadOrRefuse(objectName, oneRecordIdOrRefuse(key, value)); + } + } + } + const inputs = (start.inputs ?? []).filter(([, value]) => isNamedStartValue(value)); + if (inputs.length === 0) return; + const reads = await flowDeclaredRecordReads(automation, flowName); + for (const [key, value] of inputs) { + for (const read of reads.get(key) ?? []) { + const ids = read.many ? recordIdListOrRefuse(key, value) : [oneRecordIdOrRefuse(key, value)]; + for (const recordId of ids) await loadOrRefuse(read.object, recordId); + } + } +} + +/** Whether a start names something under a key: absent, `null` and `''` name nothing. */ +function isNamedStartValue(value: unknown): boolean { + return value !== undefined && value !== null && value !== ''; +} + +/** One record id (a string or a number), or the `400` naming the key: only an id names one row. */ +function oneRecordIdOrRefuse(key: string, value: unknown): string { + if (typeof value === 'string' || typeof value === 'number') return String(value); + const message = `The record key \`${key}\` must name one record by its id.`; + throw validationFailure(message, [{ field: key, code: 'invalid_type', message }]); +} + +/** A list of record ids, or the `400` naming the key: a flow that reads a list of ids reads nothing else. */ +function recordIdListOrRefuse(key: string, value: unknown): string[] { + if (Array.isArray(value) && value.every((id) => typeof id === 'string' || typeof id === 'number')) { + return value.map(String); } + const message = `The record key \`${key}\` must name a list of records by their ids.`; + throw validationFailure(message, [{ field: key, code: 'invalid_type', message }]); } /** diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index 292d9e8f32f..701997c7203 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -730,6 +730,18 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string const paramsFailure = objectValueFailure('params', reqBody.params); if (paramsFailure) return invalidBody(`Invalid action body — \`params\` ${paramsFailure.message}`, paramsFailure); const recordId = recordIdFromPath ?? reqBody.recordId; + // The routed record is named by ONE id, a string or a number: the subject + // load below reads the row whose `id` equals it, and every branch hands it + // on as the row's id. Any other value names no one row, so it is refused + // here, before the param contract, the load and dispatch, with the `400` + // flow start admission answers for a record key that is not one id at the + // other doors (`refuseUnreadableFlowStartRecords`). A path segment is + // always a string; absent, `null` and `''` stay the record-less call. + if (recordId !== undefined && recordId !== null && recordId !== '' + && typeof recordId !== 'string' && typeof recordId !== 'number') { + const message = 'The record key `recordId` must name one record by its id.'; + return invalidBody(`Invalid action body — ${message}`, { field: 'recordId', code: 'invalid_type', message }); + } const reqParams: Record = reqBody.params ?? {}; // [ADR-0104 D2] Enforce the declared param contract before the handler diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index e4b6e6fec5e..64ad44ac4a4 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -252,17 +252,22 @@ function recordIdAlias(objectName: unknown): string | undefined { * judged, exactly as the run receives it. Read by the trigger routes and a * declared `type: 'flow'` endpoint, which share the builder, for flow start * admission's third question (`refuseUnreadableFlowStartRecords`). + * + * `inputs` is the whole built `params` bag, row slots included: every value + * the run's input variables are bound from, each judged against the object + * the flow's own definition reads it from, where it declares one. */ export function flowStartRecordKeys(automationContext: Record): { objectName: unknown; recordKeys: FlowStartRecordKeys; + inputs: FlowStartRecordKeys; } { const params = (automationContext.params ?? {}) as Record; const objectName = automationContext.object; const alias = recordIdAlias(objectName); const recordKeys: Array = [['recordId', params.recordId]]; if (alias !== undefined) recordKeys.push([alias, params[alias]]); - return { objectName, recordKeys }; + return { objectName, recordKeys, inputs: Object.entries(params) }; } export function createAutomationDomain(deps: DomainHandlerDeps): DomainRoute { diff --git a/packages/runtime/src/flow-start-admission.ts b/packages/runtime/src/flow-start-admission.ts index bf0e130bc34..9631ae14ad2 100644 --- a/packages/runtime/src/flow-start-admission.ts +++ b/packages/runtime/src/flow-start-admission.ts @@ -57,8 +57,17 @@ * trigger routes and a declared endpoint too: a door that hands a run a row * the caller has not shown it can read is the gap this module exists to close. * It lives beside the load it reuses, so this module imports nothing from - * `action-execution.ts` and the two stay a one-way edge; the declaration it - * reads is this module's ({@link flowRunsElevated}). + * `action-execution.ts` and the two stay a one-way edge; the declarations it + * reads are this module's ({@link flowRunsElevated}, + * {@link flowDeclaredRecordReads}). + * + * The object a key is loaded from is the one the start names, AND every object + * the flow's own definition reads that input from + * ({@link flowDeclaredRecordReads}): the object a caller names is not always + * the object a run reads, so a row the caller can read in the object it named + * says nothing about the row the run reads elsewhere under the same id. Every + * start input the definition reads as a record id is judged that way, row + * slot or not, and must be an id (or a list of ids, where it is read as one). * * ## Why a module, and why every door asks it * @@ -84,6 +93,7 @@ */ import type { FlowParsed } from '@objectstack/spec/automation'; +import { collectFlowGraphs } from '@objectstack/spec/automation'; import { missingCapabilities } from './required-capabilities.js'; @@ -176,6 +186,186 @@ export async function flowRunsElevated(automation: unknown, flowName: string): P return flow?.runAs === 'system'; } +/** + * One record read a flow's own definition declares for a start input: the + * input's value is read as the id of a row of `object`, or, when `many`, as a + * list of such ids. + */ +export interface FlowDeclaredRecordRead { + object: string; + many: boolean; +} + +/** How deep {@link flowDeclaredRecordReads} follows `subflow` and `map` inputs into callee flows. */ +const DECLARED_READ_CALLEE_DEPTH = 8; + +/** The three CRUD node types that address existing rows through `config.filter`. */ +const ROW_ADDRESSING_CRUD_NODE_TYPES: ReadonlySet = new Set(['get_record', 'update_record', 'delete_record']); + +/** + * The variable a value slot hands on unchanged, or `undefined`: a CEL value + * envelope whose whole source is one variable name, bare or through `vars`. + * Anything computed (a call, an operator, a member path, a literal) is not a + * hand-off, so it is not read as one. + */ +function handedOnVariable(slot: unknown): string | undefined { + if (slot === null || typeof slot !== 'object' || Array.isArray(slot)) return undefined; + const { dialect, source } = slot as { dialect?: unknown; source?: unknown }; + if (dialect !== 'cel' || typeof source !== 'string') return undefined; + const match = /^(?:vars\.)?([A-Za-z_][A-Za-z0-9_]*)$/.exec(source.trim()); + return match ? match[1] : undefined; +} + +/** A node config's string value at `key`, when it is a non-empty literal. */ +function literalString(config: Record, key: string): string | undefined { + const value = config[key]; + return typeof value === 'string' && value !== '' ? value : undefined; +} + +/** A plain object, or `undefined`. */ +function recordOf(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** + * The record reads a flow's own definition declares for its start inputs — + * flow start admission's reading of what a run addresses, asked by + * `refuseUnreadableFlowStartRecords` (`action-execution.ts`) at every door + * that starts a flow by name. + * + * A start input is a variable the flow declares `isInput`: the engine binds it + * from the start's `params` under the same name. The definition reads such an + * input as a record id of a FIXED object in these places, and only these, + * across every region of the flow (`collectFlowGraphs`): + * + * - a `get_record`, `update_record` or `delete_record` node with a literal + * `objectName` whose `filter.id` is the input (one id), or whose + * `filter.id.$eq` / `filter.id.$in` is (one id / a list of ids); + * - a `screen` node with a literal `objectName` whose `recordId` is the input; + * - a `subflow` node with a literal `flowName` that hands the input on to a + * callee input, or a `map` node that hands it on as it is or per item (a + * callee input taking the iterator variable of a `collection` that is the + * input, which reads the input as a list): the callee's own declared reads + * of that callee input, followed {@link DECLARED_READ_CALLEE_DEPTH} levels + * deep, never re-entering a flow already on the path. + * + * "Is the input" means a CEL value envelope whose whole source is the input's + * name ({@link handedOnVariable}). A read computed from the input, or from a + * value the flow derived, declares nothing here: the definition does not say + * which object such a value addresses, so it is not judged. Nor is a name a + * `loop` or `map` node binds as its item or index variable: that binding + * replaces the start's value in the run's variables. + * + * Read through the automation service's own `getFlow`, the probe + * {@link flowStartRefusal} reads; a service that cannot be asked, or a flow it + * does not serve, declares nothing. + */ +export async function flowDeclaredRecordReads( + automation: unknown, + flowName: string, +): Promise> { + const svc = automation as { getFlow?: (name: string) => Promise } | null | undefined; + if (typeof svc?.getFlow !== 'function') return new Map(); + const getFlow = (name: string): Promise => svc.getFlow!(name); + return declaredReadsOf(getFlow, flowName, new Set(), 0); +} + +async function declaredReadsOf( + getFlow: (name: string) => Promise, + flowName: string, + onPath: Set, + depth: number, +): Promise> { + const reads = new Map(); + if (onPath.has(flowName) || depth > DECLARED_READ_CALLEE_DEPTH) return reads; + const flow = (await getFlow(flowName)) as FlowParsed | null | undefined; + if (!flow || typeof flow !== 'object') return reads; + const inputs = new Set(); + for (const variable of Array.isArray(flow.variables) ? flow.variables : []) { + if (variable && variable.isInput === true && typeof variable.name === 'string') inputs.add(variable.name); + } + const graphs = collectFlowGraphs(flow); + // A `loop` or `map` node binds its item (and index) variable over the run's + // own variables, so once it runs that name no longer holds the start's + // value: such a name is not read as a start input anywhere in the flow. + for (const graph of graphs) { + for (const node of graph.nodes) { + const type = String(node.type); + const config = recordOf(node.config); + if (config === undefined || (type !== 'loop' && type !== 'map')) continue; + const iterator = literalString(config, 'iteratorVariable') ?? (type === 'map' ? 'item' : undefined); + if (iterator !== undefined) inputs.delete(iterator); + const index = literalString(config, 'indexVariable'); + if (index !== undefined) inputs.delete(index); + } + } + if (inputs.size === 0) return reads; + const add = (input: string, read: FlowDeclaredRecordRead): void => { + const list = reads.get(input) ?? []; + if (!list.some((r) => r.object === read.object && r.many === read.many)) list.push(read); + reads.set(input, list); + }; + const inputOf = (slot: unknown): string | undefined => { + const name = handedOnVariable(slot); + return name !== undefined && inputs.has(name) ? name : undefined; + }; + onPath.add(flowName); + try { + for (const graph of graphs) { + for (const node of graph.nodes) { + const config = recordOf(node.config); + if (config === undefined) continue; + const type = String(node.type); + if (ROW_ADDRESSING_CRUD_NODE_TYPES.has(type)) { + const object = literalString(config, 'objectName'); + const filter = recordOf(config.filter); + if (object === undefined || filter === undefined) continue; + const one = inputOf(filter.id); + if (one !== undefined) add(one, { object, many: false }); + const operators = recordOf(filter.id); + const eq = operators ? inputOf(operators.$eq) : undefined; + if (eq !== undefined) add(eq, { object, many: false }); + const within = operators ? inputOf(operators.$in) : undefined; + if (within !== undefined) add(within, { object, many: true }); + } else if (type === 'screen') { + const object = literalString(config, 'objectName'); + const input = inputOf(config.recordId); + if (object !== undefined && input !== undefined) add(input, { object, many: false }); + } else if (type === 'subflow' || type === 'map') { + const callee = literalString(config, 'flowName'); + const handed = recordOf(config.input); + if (callee === undefined || handed === undefined) continue; + // What each callee input carries: one of this flow's inputs + // as it is, or (a `map`) one item of an input it iterates. + const iterated = type === 'map' ? inputOf(config.collection) : undefined; + const iterator = literalString(config, 'iteratorVariable') ?? 'item'; + const carried: Array = []; + for (const [calleeInput, slot] of Object.entries(handed)) { + const input = inputOf(slot); + if (input !== undefined) carried.push([calleeInput, input, false]); + else if (iterated !== undefined && handedOnVariable(slot) === iterator) carried.push([calleeInput, iterated, true]); + } + if (carried.length === 0) continue; + const calleeReads = await declaredReadsOf(getFlow, callee, onPath, depth + 1); + for (const [calleeInput, input, perItem] of carried) { + for (const read of calleeReads.get(calleeInput) ?? []) { + // One id read per item makes the input a list of ids; + // a list read per item (a list of lists) states no row. + if (perItem && read.many) continue; + add(input, { object: read.object, many: perItem || read.many }); + } + } + } + } + } + } finally { + onPath.delete(flowName); + } + return reads; +} + /** * THE CHECK every door that starts a flow by name asks: the refusal it * answers for this caller starting `flowName`, or `null` when the start is diff --git a/packages/runtime/src/flow-start-declared-record-reads.test.ts b/packages/runtime/src/flow-start-declared-record-reads.test.ts new file mode 100644 index 00000000000..25f8d70656c --- /dev/null +++ b/packages/runtime/src/flow-start-declared-record-reads.test.ts @@ -0,0 +1,576 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Flow start admission's third question, asked of the object the FLOW reads + * (`flow-start-admission.ts`, `flowDeclaredRecordReads`, and + * `action-execution.ts`, `refuseUnreadableFlowStartRecords`): every start + * input a flow's own definition reads as the id of a row of a fixed object is + * an id (or a list of ids, where it is read as one), and is loaded in the + * CALLER's own scope from THAT object before the run exists. + * + * What is pinned: + * + * 1. **The reading** (`flowDeclaredRecordReads`): which places in a + * definition declare a read, through which regions and callee flows, and + * which do not. + * 2. **The trigger routes**: an input judged against the object the flow + * reads it from, whatever object the start names or none; a value that is + * not an id refused `400` naming the input, nothing loaded, no run. + * 3. **A declared `type: 'flow'` endpoint**: the same refusal. + * 4. **The action door's flow branch**, REST and MCP: the routed id and the + * caller's own params are judged against the object the flow reads. + * 5. **The REST action door's routed record**: one id, or `400` naming + * `recordId` before anything is loaded or dispatched. + * 6. **What stays as it was**: a readable id starts the run (the firing + * control for every zero above), a read the definition does not declare, + * the system principal. + * + * The data double is ROW-SCOPED per object: each row is visible to its owner + * and to nobody else, and no other id exists, so the real + * `callData('get', …)` answers the real `RECORD_NOT_FOUND`. One id names a row + * in both objects, owned by different users, so "the caller reads this id" + * depends on which object it is read from. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from './http-dispatcher.js'; +import type { HttpProtocolContext } from './http-dispatcher.js'; +import { callData, invokeBusinessAction } from './action-execution.js'; +import { buildEndpointExecutionContext, executeEndpointTarget } from './endpoint-executor.js'; +import { flowDeclaredRecordReads } from './flow-start-admission.js'; +import { ApiEndpointSchema } from '@objectstack/spec/api'; +import type { ApiEndpointMatch } from '@objectstack/spec/contracts'; + +const CASE = 'crm_case'; +const CONTRACT = 'crm_contract'; +const MEMBER = 'usr_member'; +const OTHER = 'usr_other'; + +/** The member's row in both objects. */ +const BOTH = 'id_both'; +/** The member's row in `crm_case`; the same id in `crm_contract` is the other user's. */ +const CASE_ONLY = 'id_case_only'; +/** The other user's row in `crm_contract`, and nothing in `crm_case`. */ +const CONTRACT_OTHER = 'id_contract_other'; +/** An id that names nothing anywhere. */ +const GHOST = 'id_ghost'; + +const cel = (source: string) => ({ dialect: 'cel', source }); + +type FlowDef = Record & { name: string }; + +function flow(name: string, inputs: string[], nodes: unknown[], extra: Record = {}): FlowDef { + return { + name, + label: name, + type: 'screen', + status: 'active', + variables: inputs.map((input) => ({ name: input, type: 'text', isInput: true })), + nodes: [{ id: 'start', type: 'start', label: 'Start' }, ...nodes, { id: 'end', type: 'end', label: 'End' }], + edges: [], + ...extra, + }; +} + +const getRow = (object: string, filter: unknown, type = 'get_record') => ({ + id: `n_${type}_${object}`, type, label: 'Row', config: { objectName: object, filter, outputVariable: 'row' }, +}); + +const BY_RECORD = 'contract_by_record'; +const BY_CUSTOM = 'contract_by_custom'; +const BY_LIST = 'contract_by_list'; +const BY_COMPUTED = 'contract_by_computed'; +const SCREEN_EDIT = 'contract_screen_edit'; +const SUBFLOW_PARENT = 'contract_subflow_parent'; +const MAP_PARENT = 'contract_map_parent'; + +const FLOWS: Record = { + [BY_RECORD]: flow(BY_RECORD, ['recordId'], [getRow(CONTRACT, { id: cel('recordId') })], { runAs: 'system' }), + [BY_CUSTOM]: flow(BY_CUSTOM, ['contractId'], [getRow(CONTRACT, { id: cel('contractId') })], { runAs: 'system' }), + [BY_LIST]: flow(BY_LIST, ['contractIds'], [getRow(CONTRACT, { id: { $in: cel('contractIds') } })], { runAs: 'system' }), + [BY_COMPUTED]: flow(BY_COMPUTED, ['contractId'], [getRow(CONTRACT, { id: cel("contractId + ''") })], { runAs: 'system' }), + [SCREEN_EDIT]: flow(SCREEN_EDIT, ['contractId'], [ + { id: 'form', type: 'screen', label: 'Edit', config: { objectName: CONTRACT, mode: 'edit', recordId: cel('contractId') } }, + ], { runAs: 'system' }), + [SUBFLOW_PARENT]: flow(SUBFLOW_PARENT, ['target'], [ + { id: 'sub', type: 'subflow', label: 'Sub', config: { flowName: BY_CUSTOM, input: { contractId: cel('vars.target') } } }, + ]), + [MAP_PARENT]: flow(MAP_PARENT, ['contractIds'], [ + { id: 'each', type: 'map', label: 'Each', config: { collection: cel('contractIds'), flowName: BY_CUSTOM, input: { contractId: cel('item') } } }, + ]), +}; + +/** The acting principal, as `resolveExecutionContext` builds one. */ +function ec(userId: string) { + return { userId, tenantId: 'org_1', positions: [], permissions: [], systemPermissions: [] }; +} + +const CONTRACT_ACTION = { + name: 'open_contract', label: 'Open contract', objectName: CASE, type: 'flow', target: BY_RECORD, + ai: { exposed: true, description: 'Open the contract.' }, +}; +const CUSTOM_ACTION = { + name: 'open_contract_by_param', label: 'Open contract by param', objectName: CASE, type: 'flow', target: BY_CUSTOM, + params: [{ name: 'contractId', label: 'Contract', type: 'text' }], +}; +const SCRIPT_ACTION = { name: 'touch_case', label: 'Touch', objectName: CASE, type: 'script', execute: 'return 1' }; +const CASE_DEF = { name: CASE, actions: [CONTRACT_ACTION, CUSTOM_ACTION, SCRIPT_ACTION] }; +const CONTRACT_DEF = { name: CONTRACT, actions: [] }; +const schemaOf = (n: string) => (n === CASE ? CASE_DEF : n === CONTRACT ? CONTRACT_DEF : undefined); + +/** Row-scoped reads, per object: each row to its owner, nothing else exists. */ +function makeQl() { + const rows: Record> = { + [CASE]: [{ id: BOTH, owner_id: MEMBER }, { id: CASE_ONLY, owner_id: MEMBER }], + [CONTRACT]: [{ id: BOTH, owner_id: MEMBER }, { id: CASE_ONLY, owner_id: OTHER }, { id: CONTRACT_OTHER, owner_id: OTHER }], + }; + return { + executeAction: vi.fn(async () => ({ ok: true })), + getSchema: schemaOf, + registry: { getObject: schemaOf, getItem: () => undefined }, + find: vi.fn(async (object: string, options?: any) => { + const caller = options?.context?.isSystem ? '*' : options?.context?.userId; + const wanted = options?.where?.id; + return (rows[object] ?? []).filter((r) => (wanted === undefined || r.id === wanted) + && (caller === '*' || r.owner_id === caller)); + }), + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + } as any; +} + +function makeAutomation() { + return { + handlerReady: true, + execute: vi.fn(async (_flow: string, _context?: any) => ({ + success: true, status: 'paused', runId: 'run_test', durationMs: 1, + })), + getFlow: vi.fn(async (name: string) => FLOWS[name] ?? null), + }; +} + +function makeMetadata() { + return { + load: vi.fn(async () => null), + loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })), + loadMany: vi.fn(async () => []), + listObjects: vi.fn(async () => [CASE_DEF, CONTRACT_DEF]), + getObject: vi.fn(async (n: string) => schemaOf(n)), + }; +} + +function boot(principal: Record) { + const ql = makeQl(); + const automation = makeAutomation(); + const metadata = makeMetadata(); + const resolve = (n: string): unknown => + n === 'objectql' || n === 'data' ? ql + : n === 'metadata' ? metadata + : n === 'automation' ? automation + : undefined; + const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; + const dispatcher = new HttpDispatcher(kernel); + // The seam `dispatch` writes `context.executionContext` from. + (dispatcher as any).timedResolveExecutionContext = async () => ({ ...principal }); + const context = (): HttpProtocolContext => + ({ request: {}, environmentId: 'platform', executionContext: { ...principal } }) as any; + return { + ql, + automation, + /** The wire answer through the registered trigger route. */ + trigger: (flowName: string, body: unknown, legacy = false) => + dispatcher.dispatch('POST', legacy ? `/automation/trigger/${flowName}` : `/automation/${flowName}/trigger`, + body, {}, { request: {}, environmentId: 'platform' } as HttpProtocolContext), + /** The REST action door. */ + actions: (path: string, body?: unknown) => (dispatcher as any).handleActions(path, 'POST', body ?? {}, context()), + /** The MCP `run_action` bridge, on the REAL `callData`. */ + runAction: (name: string, input: Record) => { + const deps: any = { + resolveService: async (_ctx: any, service: string) => (service === 'automation' ? automation : undefined), + getObjectQL: async () => ql, + }; + const requestContext: any = { request: {}, environmentId: 'platform' }; + return invokeBusinessAction(deps, requestContext, name, input as any, { + driver: undefined, + envId: 'platform', + ec: { ...principal }, + getMeta: () => metadata, + callData: (action, params, dataDriver, scopeId, execCtx) => + callData(deps, requestContext, action, params, dataDriver, scopeId, execCtx), + }); + }, + }; +} + +const statusOf = (r: any): unknown => r?.response?.status; +const errorOf = (r: any): any => r?.response?.body?.error ?? {}; +/** Every row read, `[object, id]`. */ +const reads = (h: { ql: any }): Array<[string, unknown]> => + h.ql.find.mock.calls.map((c: any[]) => [c[0], c[1]?.where?.id]); +/** The refusal envelope with the id normalised out, so two ids compare equal. */ +const envelopeOf = (r: any, id: string) => ({ + status: statusOf(r), + code: errorOf(r).code, + message: String(errorOf(r).message ?? '').split(id).join(''), +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 1. The reading +// ─────────────────────────────────────────────────────────────────────────── + +describe('flowDeclaredRecordReads: the record reads a definition declares for its start inputs', () => { + const readsOf = (defs: FlowDef[], name = defs[0]!.name) => + flowDeclaredRecordReads({ getFlow: async (n: string) => defs.find((d) => d.name === n) ?? null }, name) + .then((m) => Object.fromEntries(m)); + + it.each(['get_record', 'update_record', 'delete_record'])('a `%s` node whose `filter.id` is the input: one id of that object', async (type) => { + expect(await readsOf([flow('f', ['x'], [getRow(CONTRACT, { id: cel('x') }, type)])])).toEqual({ x: [{ object: CONTRACT, many: false }] }); + }); + + it('`filter.id.$eq` reads one id, `filter.id.$in` a list of ids', async () => { + expect(await readsOf([flow('f', ['a', 'b'], [getRow(CONTRACT, { id: { $eq: cel('a') } }), getRow(CASE, { id: { $in: cel('b') } })])])) + .toEqual({ a: [{ object: CONTRACT, many: false }], b: [{ object: CASE, many: true }] }); + }); + + it('the `vars.` spelling of the input is the input', async () => { + expect(await readsOf([flow('f', ['x'], [getRow(CONTRACT, { id: cel('vars.x') })])])).toEqual({ x: [{ object: CONTRACT, many: false }] }); + }); + + it('a screen edit form whose `recordId` is the input', async () => { + expect(await readsOf([FLOWS[SCREEN_EDIT]!])).toEqual({ contractId: [{ object: CONTRACT, many: false }] }); + }); + + it('a node inside a structured region (a loop body, a try region) declares its read too', async () => { + const looped = flow('f', ['x', 'y'], [ + { id: 'loop', type: 'loop', label: 'Loop', config: { collection: [1], iteratorVariable: 'it', body: { nodes: [getRow(CONTRACT, { id: cel('x') })], edges: [] } } }, + { id: 'try', type: 'try_catch', label: 'Try', config: { try: { nodes: [getRow(CASE, { id: cel('y') })], edges: [] } } }, + ]); + expect(await readsOf([looped])).toEqual({ x: [{ object: CONTRACT, many: false }], y: [{ object: CASE, many: false }] }); + }); + + it('through a subflow hand-off, and a map that hands each item on (a list of ids)', async () => { + expect(await readsOf(Object.values(FLOWS), SUBFLOW_PARENT)).toEqual({ target: [{ object: CONTRACT, many: false }] }); + expect(await readsOf(Object.values(FLOWS), MAP_PARENT)).toEqual({ contractIds: [{ object: CONTRACT, many: true }] }); + }); + + it('a flow that calls itself is read once, and the reading ends', async () => { + const self = flow('f', ['x'], [ + getRow(CONTRACT, { id: cel('x') }), + { id: 'again', type: 'subflow', label: 'Again', config: { flowName: 'f', input: { x: cel('x') } } }, + ]); + expect(await readsOf([self])).toEqual({ x: [{ object: CONTRACT, many: false }] }); + }); + + it('declares nothing for a value that is not a start input, is computed, or addresses an object the definition does not name', async () => { + const nothing = flow('f', ['x'], [ + getRow(CONTRACT, { id: cel('notAnInput') }), + getRow(CONTRACT, { id: cel("x + ''") }), + getRow(CONTRACT, { name: cel('x') }), + { id: 'dyn', type: 'get_record', label: 'Dyn', config: { objectName: cel('x'), filter: { id: cel('x') } } }, + ]); + (nothing.variables as unknown[]).push({ name: 'notAnInput', type: 'text' }); + expect(await readsOf([nothing])).toEqual({}); + }); + + it('a name a loop or map binds as its item is not read as the start input it shadows', async () => { + const shadowed = flow('f', ['x'], [ + { id: 'loop', type: 'loop', label: 'Loop', config: { collection: [1], iteratorVariable: 'x', body: { nodes: [getRow(CONTRACT, { id: cel('x') })], edges: [] } } }, + ]); + expect(await readsOf([shadowed])).toEqual({}); + }); + + it('a service that cannot be asked, or a flow it does not serve, declares nothing', async () => { + expect((await flowDeclaredRecordReads({}, BY_CUSTOM)).size).toBe(0); + expect(await readsOf([], 'missing')).toEqual({}); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 2. The trigger routes +// ─────────────────────────────────────────────────────────────────────────── + +describe('the trigger routes judge each start input against the object the flow reads it from', () => { + for (const legacy of [false, true]) { + const label = legacy ? 'legacy POST /automation/trigger/:name' : 'POST /automation/:name/trigger'; + describe(label, () => { + it('a record key readable in the object the start names, but not in the object the flow reads: 404, no run', async () => { + const h = boot(ec(MEMBER)); + const res = await h.trigger(BY_RECORD, { recordId: CASE_ONLY, objectName: CASE }, legacy); + + expect(statusOf(res)).toBe(404); + expect(errorOf(res).code).toBe('RECORD_NOT_FOUND'); + expect(reads(h)).toContainEqual([CONTRACT, CASE_ONLY]); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + + it('a record key readable in both: the run starts, the firing control', async () => { + const h = boot(ec(MEMBER)); + const res = await h.trigger(BY_RECORD, { recordId: BOTH, objectName: CASE }, legacy); + + expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200); + expect(h.automation.execute).toHaveBeenCalledTimes(1); + expect(reads(h)).toContainEqual([CONTRACT, BOTH]); + }); + }); + } + + it('a custom input the flow reads as an id the caller cannot read: 404, no run', async () => { + const h = boot(ec(MEMBER)); + const res = await h.trigger(BY_CUSTOM, { params: { contractId: CONTRACT_OTHER } }); + + expect(statusOf(res)).toBe(404); + expect(errorOf(res).code).toBe('RECORD_NOT_FOUND'); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + + it('…and one it can read starts the run with the value as given', async () => { + const h = boot(ec(MEMBER)); + const res = await h.trigger(BY_CUSTOM, { params: { contractId: BOTH } }); + + expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200); + expect(h.automation.execute.mock.calls[0]![1].params).toMatchObject({ contractId: BOTH }); + }); + + it.each([ + ['an object', { id: BOTH }], + ['a list', [BOTH]], + ['a boolean', true], + ])('a custom input the flow reads as one id, given %s: 400 naming the input, nothing loaded, no run', async (_label, value) => { + const h = boot(ec(MEMBER)); + const res = await h.trigger(BY_CUSTOM, { params: { contractId: value } }); + + expect(statusOf(res)).toBe(400); + expect(errorOf(res).code).toBe('VALIDATION_FAILED'); + expect(String(errorOf(res).message)).toContain('`contractId`'); + expect(JSON.stringify(errorOf(res).details ?? {})).toContain('contractId'); + expect(reads(h)).toHaveLength(0); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + + it('an unreadable id and an id that names nothing are one refusal', async () => { + const denied = await boot(ec(MEMBER)).trigger(BY_CUSTOM, { params: { contractId: CONTRACT_OTHER } }); + const nothing = await boot(ec(MEMBER)).trigger(BY_CUSTOM, { params: { contractId: GHOST } }); + + expect(envelopeOf(nothing, GHOST)).toEqual(envelopeOf(denied, CONTRACT_OTHER)); + }); + + describe('an input the flow reads as a list of ids', () => { + it('every id readable: the run starts', async () => { + const h = boot(ec(MEMBER)); + const res = await h.trigger(BY_LIST, { params: { contractIds: [BOTH] } }); + + expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200); + expect(h.automation.execute).toHaveBeenCalledTimes(1); + }); + + it('one id unreadable: 404, no run', async () => { + const h = boot(ec(MEMBER)); + const res = await h.trigger(BY_LIST, { params: { contractIds: [BOTH, CONTRACT_OTHER] } }); + + expect(statusOf(res)).toBe(404); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + + it.each([ + ['one id', BOTH], + ['a list holding an object', [{ id: BOTH }]], + ])('given %s: 400 naming the input, nothing loaded, no run', async (_label, value) => { + const h = boot(ec(MEMBER)); + const res = await h.trigger(BY_LIST, { params: { contractIds: value } }); + + expect(statusOf(res)).toBe(400); + expect(String(errorOf(res).message)).toContain('`contractIds`'); + expect(reads(h)).toHaveLength(0); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + }); + + it.each([ + ['a subflow it hands the input to', SUBFLOW_PARENT, { target: CONTRACT_OTHER }], + ['a map that hands each item on', MAP_PARENT, { contractIds: [BOTH, CONTRACT_OTHER] }], + ['a screen edit form', SCREEN_EDIT, { contractId: CONTRACT_OTHER }], + ])('a read the flow declares through %s: 404, no run, whatever the flow runs as and with no object named', async (_label, flowName, params) => { + const h = boot(ec(MEMBER)); + const res = await h.trigger(flowName, { params }); + + expect(statusOf(res)).toBe(404); + expect(errorOf(res).code).toBe('RECORD_NOT_FOUND'); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); +}); + +describe('what the trigger routes keep exactly as they were', () => { + it('a read the definition does not declare (a computed id) is not judged', async () => { + const h = boot(ec(MEMBER)); + const res = await h.trigger(BY_COMPUTED, { params: { contractId: CONTRACT_OTHER } }); + + expect(statusOf(res)).toBe(200); + expect(h.automation.execute).toHaveBeenCalledTimes(1); + expect(reads(h)).toHaveLength(0); + }); + + it('the system principal starts on any id, nothing loaded', async () => { + const h = boot({ isSystem: true }); + const res = await h.trigger(BY_CUSTOM, { params: { contractId: CONTRACT_OTHER } }); + + expect(statusOf(res)).toBe(200); + expect(h.automation.execute).toHaveBeenCalledTimes(1); + expect(reads(h)).toHaveLength(0); + }); + + it('an input the flow does not read as an id is not judged', async () => { + const h = boot(ec(MEMBER)); + const res = await h.trigger(BY_CUSTOM, { params: { contractId: BOTH, note: { any: 'shape' } } }); + + expect(statusOf(res)).toBe(200); + expect(h.automation.execute).toHaveBeenCalledTimes(1); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 3. A declared `type: 'flow'` endpoint +// ─────────────────────────────────────────────────────────────────────────── + +describe('a declared `type: flow` endpoint asks the same question', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'contract_open', path: '/api/v1/apps/contract/open', method: 'POST', type: 'flow', target: BY_CUSTOM, + }); + + async function run(userId: string, body: unknown) { + const ql = makeQl(); + const automation = makeAutomation(); + const deps: any = { resolveService: async () => undefined, getObjectQL: async () => ql }; + const protocolContext: any = { request: {}, environmentId: 'platform', executionContext: ec(userId) }; + const match: ApiEndpointMatch = { endpoint, params: {} }; + const answer = await executeEndpointTarget( + buildEndpointExecutionContext({ + request: { method: 'POST', path: endpoint.path, query: {}, headers: {}, body }, + match, + executionContext: ec(userId) as any, + environmentId: 'platform', + }), + { + automationService: automation, + callData: (action, params, dataDriver, scopeId, execCtx) => + callData(deps, protocolContext, action, params, dataDriver, scopeId, execCtx), + }, + ); + return { answer, automation }; + } + + it('an input the flow reads as an id the caller cannot read: 404, no run', async () => { + const { answer, automation } = await run(MEMBER, { params: { contractId: CONTRACT_OTHER } }); + + expect(answer.status).toBe(404); + expect((answer.body as any)?.error?.code).toBe('RECORD_NOT_FOUND'); + expect(automation.execute).not.toHaveBeenCalled(); + }); + + it('one it can read starts the run: the firing control', async () => { + const { answer, automation } = await run(MEMBER, { params: { contractId: BOTH } }); + + expect(answer.status).toBe(200); + expect(automation.execute).toHaveBeenCalledTimes(1); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 4. The action door's flow branch +// ─────────────────────────────────────────────────────────────────────────── + +describe('the action door judges what it hands the run against the object the flow reads', () => { + it('REST: a routed row readable in the action\'s object, not in the object the flow reads: 404, no run', async () => { + const h = boot(ec(MEMBER)); + const res = await h.actions(`/${CASE}/${CONTRACT_ACTION.name}/${CASE_ONLY}`); + + expect(statusOf(res)).toBe(404); + expect(errorOf(res).code).toBe('RECORD_NOT_FOUND'); + expect(reads(h)).toContainEqual([CONTRACT, CASE_ONLY]); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + + it('REST: a routed row readable in both starts the run: the firing control', async () => { + const h = boot(ec(MEMBER)); + const res = await h.actions(`/${CASE}/${CONTRACT_ACTION.name}/${BOTH}`); + + expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200); + expect(h.automation.execute).toHaveBeenCalledTimes(1); + }); + + it('MCP: the same routed row is thrown the same refusal, no run', async () => { + const h = boot(ec(MEMBER)); + + await expect(h.runAction(CONTRACT_ACTION.name, { objectName: CASE, recordId: CASE_ONLY })) + .rejects.toMatchObject({ code: 'RECORD_NOT_FOUND', status: 404 }); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + + it('REST: a declared action param the flow reads as an id the caller cannot read: 404, no run', async () => { + const h = boot(ec(MEMBER)); + const res = await h.actions(`/${CASE}/${CUSTOM_ACTION.name}/${BOTH}`, { params: { contractId: CONTRACT_OTHER } }); + + expect(statusOf(res)).toBe(404); + expect(errorOf(res).code).toBe('RECORD_NOT_FOUND'); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + + it('REST: …and one it can read starts the run', async () => { + const h = boot(ec(MEMBER)); + const res = await h.actions(`/${CASE}/${CUSTOM_ACTION.name}/${BOTH}`, { params: { contractId: BOTH } }); + + expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200); + expect(h.automation.execute).toHaveBeenCalledTimes(1); + }); + + it('the trigger route and the action door answer one refusal for one row', async () => { + const viaTrigger = await boot(ec(MEMBER)).trigger(BY_RECORD, { recordId: CASE_ONLY, objectName: CASE }); + const viaAction = await boot(ec(MEMBER)).actions(`/${CASE}/${CONTRACT_ACTION.name}/${CASE_ONLY}`); + + expect(envelopeOf(viaAction, CASE_ONLY)).toEqual(envelopeOf(viaTrigger, CASE_ONLY)); + expect(envelopeOf(viaAction, CASE_ONLY)).toMatchObject({ status: 404, code: 'RECORD_NOT_FOUND' }); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 5. The REST action door's routed record is one id +// ─────────────────────────────────────────────────────────────────────────── + +describe('the REST action door refuses a routed record that is not one id, before anything is loaded or run', () => { + const NOT_ONE_ID: Array<[string, unknown]> = [ + ['an object', { id: BOTH }], + ['a list', [BOTH]], + ['a boolean', true], + ]; + + for (const action of [CONTRACT_ACTION, SCRIPT_ACTION]) { + it.each(NOT_ONE_ID)(`${action.type} action: \`recordId\` given %s: 400 VALIDATION_FAILED naming \`recordId\``, async (_label, value) => { + const h = boot(ec(MEMBER)); + const res = await h.actions(`/${CASE}/${action.name}`, { recordId: value }); + + expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(400); + expect(errorOf(res).code).toBe('VALIDATION_FAILED'); + expect(errorOf(res).details?.fields).toMatchObject([{ field: 'recordId', code: 'invalid_type' }]); + expect(String(errorOf(res).message)).toMatch(/^Invalid action body/); + expect(reads(h)).toHaveLength(0); + expect(h.automation.execute).not.toHaveBeenCalled(); + expect(h.ql.executeAction).not.toHaveBeenCalled(); + }); + } + + it('a string id in the body is loaded as before: the firing control', async () => { + const h = boot(ec(MEMBER)); + const res = await h.actions(`/${CASE}/${CONTRACT_ACTION.name}`, { recordId: BOTH }); + + expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200); + expect(reads(h)).toContainEqual([CASE, BOTH]); + }); + + it('a number id in the body passes the shape check and is loaded', async () => { + const h = boot(ec(MEMBER)); + const res = await h.actions(`/${CASE}/${CONTRACT_ACTION.name}`, { recordId: 42 }); + + expect(statusOf(res)).toBe(404); + expect(errorOf(res).code).toBe('RECORD_NOT_FOUND'); + expect(reads(h).length).toBeGreaterThan(0); + }); +}); From 1ffeb77cc18ed85fa628ece0052e2fdfd6258ae8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:05:09 +0000 Subject: [PATCH 2/2] docs: state the declared record read rule on the flow and action pages The flow trigger route's 404 RECORD_NOT_FOUND row now says that a start input the flow's definition reads as a record id of a fixed object is loaded in the caller's scope from that object, and must be one id or a list of ids. The action page says a body recordId must be one id. Claude-Session: https://claude.ai/code/session_01ADHkmRp2YLTpRc6Vfw6TD3 Co-authored-by: Claude --- content/docs/automation/flows.mdx | 2 +- content/docs/ui/actions.mdx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index b9e33eb31c6..c649ce6a703 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -2482,7 +2482,7 @@ door's own admission check, made before anything is dispatched: | `404` | — | No flow by that name in this deployment | Check the name; nothing was dispatched | | `403` | `PERMISSION_DENIED` | The caller is not the system principal, and the flow is declared `runAs: 'system'` with a self-triggered `type` (`autolaunched`, `record_change`, `schedule`). The same answer at every door that starts a flow by name: this route, a `type: 'flow'` action (REST and MCP `run_action`) and a declared `type: 'flow'` endpoint | Let it run on its own trigger, or call it from a parent flow's `subflow` node. If members are meant to start it, declare it `type: 'screen'` (or `'api'` for a signed inbound hook) | | `403` | `PERMISSION_DENIED` | The flow declares `requiredPermissions` and the caller, not the system principal, does not hold every capability it names. Checked after the row above, at the same doors; the message names the declared and the missing capabilities | Grant the capability through a permission set's `systemPermissions`, or start the flow as someone who holds it | -| `404` | `RECORD_NOT_FOUND` | The start names a record (`recordId`, or the object's id alias such as `crmLeadId`) that the caller, not the system principal, cannot read in its own scope, or that does not exist: the two get one answer, the one the action door gives. Checked after the two rows above, at the same doors. A record key that is not one id, or a record named with no `objectName` for a flow declared `runAs: 'system'`, is refused `400` `VALIDATION_FAILED` naming the key | Start the flow on a record the caller can read, and name the record's object (`objectName`) beside `recordId` | +| `404` | `RECORD_NOT_FOUND` | The start names a record (`recordId`, or the object's id alias such as `crmLeadId`) that the caller, not the system principal, cannot read in its own scope, or that does not exist: the two get one answer, the one the action door gives. Checked after the two rows above, at the same doors. A record key that is not one id, or a record named with no `objectName` for a flow declared `runAs: 'system'`, is refused `400` `VALIDATION_FAILED` naming the key. Where the flow's own definition reads a start input as the id of a record of a fixed object (a `get_record`, `update_record` or `delete_record` node with a literal `objectName` whose `filter.id` or `filter.id.$eq` is the input, or whose `filter.id.$in` is; a `screen` node with a literal `objectName` whose `recordId` is the input; a `subflow` or `map` node that hands the input to a callee input the callee reads so), that input is also loaded in the caller's own scope from THAT object, whatever object the start names, or none, and whatever the flow runs as: a value the caller cannot read there gets this `404`, and a value that is not one id (or a list of ids, where the definition reads a list) is refused `400` `VALIDATION_FAILED` naming the input | Start the flow on a record the caller can read, and name the record's object (`objectName`) beside `recordId`. Hand an input the flow reads as a record id the id of a record the caller can read in the object the flow reads it from, in the shape the flow reads (one id, or a list of ids) | | `409` | `FLOW_DISABLED` | The flow exists but is switched off | Enable it — the identical request then succeeds | | `422` | `FLOW_NO_START_NODE` | The stored definition has no `start` node | Fix the flow; retrying cannot help | | `422` | `FLOW_INPUT_SCHEMA_INVALID` | A node's `config` violates the `inputSchema` its own definition declares | Fix the flow; the refusal is non-retryable — even under `errorHandling.strategy: 'retry'` the engine refuses once and never enters the retry loop | diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx index 7b117611490..23d4762ea4d 100644 --- a/content/docs/ui/actions.mdx +++ b/content/docs/ui/actions.mdx @@ -571,6 +571,11 @@ the server resolves your declaration by name and derives the handler key from it. Rename the underlying function freely; as long as the declaration's `target` follows, the public URL is unchanged. +A body `recordId` names one record by its id, a string or a number. Any other +value is refused `400 VALIDATION_FAILED` naming `recordId`, before the declared +param contract, the record load and dispatch, for every action type. An absent, +`null` or empty `recordId` is the record-less call. + Failures speak HTTP — the status code is the signal (#3962): - **400** — the action ran and **rejected** (a business rule said no, or