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
14 changes: 14 additions & 0 deletions .changeset/32861-flow-answer-dropped-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/spec': patch
'@objectstack/service-automation': patch
---

fix(automation): a flow run's answer names the fields its write steps did not store, so a record button whose flow writes a read-only field no longer answers a plain success

Clause-②: yes (widening)
Metadata-protocol: none

- **`@objectstack/spec`: `AutomationResult.droppedFields?: DroppedFieldsEvent[]`**, mirrored on `TriggerFlowResponseSchema.data.droppedFields` (the parity guard binds the two member for member). The element is the data engine's own `DroppedFieldsEventSchema` (`object`, `fields`, `reason`), so there is no new vocabulary. Set on a terminal success only, and only when a write step dropped a field; absent otherwise.
- **`@objectstack/service-automation`**: a `create_record` / `update_record` step whose caller-supplied values the engine legally stripped (a `readonly` field, a `readonlyWhen` lock, a computed field, …) already succeeded with a warning on its step; it now also carries the structured events, and the run's terminal answer folds them, one event per object and reason. A drop before a pause reaches the answer the resumed run gives, and a field named means at least one write dropped it. A `subflow` child that completes without pausing rolls its drops up onto its parent's answer; a child that paused, and a `map` item's run, do not yet (#33019), so for those an absent member is not proof that nothing was dropped. The run's outcome does not change: `success`, `status` and `summary` read as before.

Every door that relays the run's answer carries it unchanged: an action of `type: 'flow'` (`POST /api/v1/actions/:object/:action`, `POST /api/v1/actions/:object/:action/:recordId`, the MCP `run_action` tool), `POST /api/v1/automation/:name/trigger`, and the resume door that delivers a paused run's final answer (`POST /api/v1/automation/:name/runs/:runId/resume`, the MCP `resume_run` tool). A caller that reads the answer can now tell the person who clicked that, for example, `status` did not take effect because it is read-only.
1 change: 1 addition & 0 deletions content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,7 @@ const result = AutomationApiErrorCode.parse(data);
| **flowLabel** | `string` | optional | The flow definition's authored `label`, copied verbatim so a runner can name the flow (header, completion toast) and translate it against `flows.<flow>.label`. Set on every result of an evaluation of a registered flow (paused and terminal alike); absent on a refusal carrying `code`. For a subflow chain it is the addressed (parent) run's flow. Never defaulted to the API name |
| **refusalMessage** | `string` | optional | Rendered refusal, set when `status` is `refused` - the `end` node's `message` template interpolated against the run's variables, so it names the record. Authored per-record text (not a flow-level copy like the two above); absent on every other status. A runner shows it with Close only |
| **summary** | `{ selected: integer; acted: integer; skipped: integer; unmeasured?: integer; … }` | optional | What the run did - records selected / acted on, gate skips, per-node status. Set on a TERMINAL result (a paused run has not finished doing it yet). |
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Caller-supplied field values a write step of the run did not store (e.g. a `readonly` field an `update_record` step wrote): the data engine's own drop events, one per object and reason in first-seen order; a field named means at least one write dropped it, not every write. The steps still succeeded and the run's outcome is unchanged - this tells the caller which values never landed. Set on a terminal success only, and only when a field was dropped. Delegated writes: a `subflow` child that completes without pausing rolls its drops up here; a child that paused, and a `map` item's run, do not yet, so an absent member is not proof that a delegated write dropped nothing. |


---
Expand Down
115 changes: 115 additions & 0 deletions packages/runtime/src/action-flow-dropped-fields-relay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #32861 — the action door relays the run's `droppedFields` to whoever pressed
* the button, through BOTH entrances to `dispatchFlowAction`: REST
* `POST /actions/:object/:action` (`HttpDispatcher.handleActions`, whose
* `data` is the run's `AutomationResult`) and the MCP `run_action` bridge
* (`invokeBusinessAction`, whose `result` is the same value).
*
* The door adds nothing and strips nothing — measured before this card: it
* relays the result whole, so the run's answer is the one place the drop
* could go missing, and that is where #32861 put it
* (`service-automation`'s `run-dropped-fields.ts`). These pins hold the
* door's half of the bargain with a scripted automation service, so a later
* re-shaping of the door's answer (a projection, an allow-list) cannot drop
* the member without going red. The wire half — the real engine, a real
* read-only strip — is `@objectstack/verify`'s
* `action-flow-dropped-fields.test.ts`.
*/

import { describe, it, expect, vi } from 'vitest';

import { HttpDispatcher } from './http-dispatcher.js';
import { callData, invokeBusinessAction } from './action-execution.js';

// Paid at collection, not inside a case's clock (check:test-source-alias).
import '@objectstack/metadata';

const OBJECT = 'afr_report';
const FLOW = 'afr_submit';
const ACTION = 'submit_for_approval';
const MEMBER_EC = { userId: 'user_1', systemPermissions: [] as string[] };

const DROPPED = [{ object: OBJECT, fields: ['status'], reason: 'readonly' }];

function makeDoor(result: Record<string, unknown>) {
const execute = vi.fn(async () => result);
const getFlow = vi.fn(async (name: string) => (name === FLOW ? { name, type: 'autolaunched', runAs: 'user' } : null));
const automation = { execute, getFlow };

const action = {
name: ACTION, label: 'Submit for approval', objectName: OBJECT, type: 'flow', target: FLOW,
ai: { exposed: true, description: 'Submits the report for approval for this test.' },
};
const objectDef = { name: OBJECT, actions: [action] };
const schemaOf = (n: string) => (n === OBJECT ? objectDef : undefined);
const ql: any = {
executeAction: vi.fn(async () => ({ ran: 'script' })),
getSchema: schemaOf,
registry: { getObject: schemaOf, getItem: () => undefined },
find: vi.fn(async () => []),
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
};
const metadata: any = {
load: vi.fn(async () => null),
loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })),
listObjects: vi.fn(async () => [objectDef]),
getObject: vi.fn(async (n: string) => schemaOf(n)),
};
const resolve = (n: string) =>
n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : n === 'automation' ? automation : null;
const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } };

const viaRest = async (): Promise<any> => {
const res: any = await new HttpDispatcher(kernel).handleActions(`/${OBJECT}/${ACTION}`, 'POST', {}, {
request: {}, environmentId: 'platform', executionContext: MEMBER_EC,
} as any);
expect(res.response?.status).toBe(200);
return res.response?.body?.data;
};

const viaMcp = async (): Promise<any> => {
const request: any = { request: {}, environmentId: 'platform', executionContext: MEMBER_EC };
const deps: any = {
resolveService: async (_ctx: any, name: string) => (name === 'automation' ? automation : undefined),
getObjectQL: async () => ql,
};
const answered: any = await invokeBusinessAction(deps, request, ACTION, { objectName: OBJECT } as any, {
driver: undefined,
envId: 'platform',
ec: MEMBER_EC,
getMeta: () => ({ listObjects: async () => [objectDef] }),
callData: (a, params, dataDriver, scopeId, execCtx) =>
callData(deps, request, a, params, dataDriver, scopeId, execCtx),
});
return answered.result;
};

return { viaRest, viaMcp, execute };
}

const ENTRANCES = ['REST /actions', 'MCP run_action'] as const;

describe('the action door relays the run\'s droppedFields (#32861)', () => {
for (const entrance of ENTRANCES) {
it(`${entrance}: a run that dropped a read-only field answers it to the caller, beside an unchanged success`, async () => {
const door = makeDoor({ success: true, output: {}, droppedFields: DROPPED });

const answer = entrance === 'REST /actions' ? await door.viaRest() : await door.viaMcp();

expect(door.execute).toHaveBeenCalledTimes(1);
expect(answer.success).toBe(true);
expect(answer.droppedFields).toEqual(DROPPED);
});

it(`${entrance}: a run that dropped nothing answers without the member`, async () => {
const door = makeDoor({ success: true, output: {} });

const answer = entrance === 'REST /actions' ? await door.viaRest() : await door.viaMcp();

expect(answer.success).toBe(true);
expect('droppedFields' in answer).toBe(false);
});
}
});
12 changes: 10 additions & 2 deletions packages/services/service-automation/src/builtin/crud-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,7 +778,11 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
...(droppedFields.length > 0 ? { droppedFields } : {}),
},
...(dropped.length > 0
? { warnings: dropped.map((e) => droppedFieldsWarning('create_record', e)) }
? {
warnings: dropped.map((e) => droppedFieldsWarning('create_record', e)),
// #32861 — the events themselves, for the run's answer.
droppedFields: dropped,
}
: {}),
metrics: { acted: 1 },
};
Expand Down Expand Up @@ -926,7 +930,11 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
...(droppedFields.length > 0 ? { droppedFields } : {}),
},
...(dropped.length > 0
? { warnings: dropped.map((e) => droppedFieldsWarning('update_record', e)) }
? {
warnings: dropped.map((e) => droppedFieldsWarning('update_record', e)),
// #32861 — the events themselves, for the run's answer.
droppedFields: dropped,
}
: {}),
metrics: { acted: writtenRowCount(result) },
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ export function registerSubflowNode(engine: AutomationEngine, ctx: PluginContext
success: true as const,
output: { output: child.output ?? null },
...(metrics ? { metrics } : {}),
// #32861 — the values the child's write steps did not store ride up
// onto this step, so the parent's answer (`AutomationResult.droppedFields`)
// tells the caller about a drop its delegated write met. Set by the
// child's terminal success only, so a failed child carries none.
...(child.droppedFields?.length ? { droppedFields: child.droppedFields } : {}),
};

// [#18110] The child REFUSED — an `end` inside it declared
Expand Down
8 changes: 7 additions & 1 deletion packages/services/service-automation/src/dag-traversal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { isGuardRefusal } from './guard-refusal.js';
import { gradeAfterTenantCurrencyReadFault } from './builtin/template.js';
import { attachCarriedRefusal, readCarriedRefusal } from './inner-refusal.js';
import { summarizeRun } from './run-summary.js';
import { withDroppedFields } from './run-dropped-fields.js';
import { describeThrownForLog } from './thrown-cause-diagnostics.js';
// The values below (`AutomationEngine` for its public static
// `MAX_NODE_REENTRIES`) are read only inside the function bodies, at call time,
Expand Down Expand Up @@ -414,6 +415,7 @@ export async function executeNode(
durationMs: Date.now() - stepStart,
error: { code: 'NODE_FAILURE', message: errMsg },
...(result.warnings?.length ? { warnings: result.warnings } : {}),
...(result.droppedFields?.length ? { droppedFields: result.droppedFields } : {}),
// #4354 — a node that failed PART WAY may still have written
// rows; dropping its counts would understate what the run did.
...(result.metrics ? { metrics: result.metrics } : {}),
Expand Down Expand Up @@ -494,6 +496,9 @@ export async function executeNode(
completedAt: new Date().toISOString(),
durationMs: Date.now() - stepStart,
...(result.warnings?.length ? { warnings: result.warnings } : {}),
// #32861 — the structured events behind those warnings, folded
// onto the run's terminal answer (`dropEventsOf`).
...(result.droppedFields?.length ? { droppedFields: result.droppedFields } : {}),
...(result.metrics ? { metrics: result.metrics } : {}),
});

Expand Down Expand Up @@ -1358,7 +1363,8 @@ export async function executeWithoutRetry(
const successMessage = this.renderFlowTerminalMessage(
flowName, 'successMessage', flow.successMessage, variables, runContext,
);
return { success: true, output, durationMs, successMessage, flowLabel: flow.label, summary };
// #32861 — same fold as `execute()`'s terminal exit, over this attempt's steps.
return { success: true, output, durationMs, successMessage, flowLabel: flow.label, summary, ...withDroppedFields(steps) };
} catch (err: unknown) {
// [#15788] The THIRD producer: an attempt that reached a refusing
// `end`. A flow under `errorHandling.strategy: 'retry'` is handed
Expand Down
23 changes: 23 additions & 0 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ import { runTriggerUserId, type SeededRunContext } from './runtime-identity.js';
import { flowContentChecksum } from './flow-identity.js';
import { carriedRefusalOfResult, readCarriedRefusal, type CarriedRefusal } from './inner-refusal.js';
import { summarizeRun } from './run-summary.js';
import { withDroppedFields } from './run-dropped-fields.js';
import type { DroppedFieldsEvent } from '@objectstack/spec/data';
// #5660 — the degrade registration reports a FOREIGN failure (a third-party
// provider factory's text), so it renders it as structured `meta` rather than
// interpolating it into the log message. See ./thrown-cause-diagnostics.ts.
Expand Down Expand Up @@ -432,6 +434,16 @@ export interface NodeExecutionResult {
* copies them onto the {@link StepLogEntry}.
*/
warnings?: string[];
/**
* #32861: the data engine's own drop events behind the strip warnings
* above — the structured half a write node (`create_record` /
* `update_record`) received from `onFieldsDropped`. Copied onto the
* {@link StepLogEntry} beside `warnings`, and folded from there onto the
* run's terminal answer (`AutomationResult.droppedFields`), so the door
* that started the run can tell its caller which values never landed.
* Omitted when nothing was dropped.
*/
droppedFields?: DroppedFieldsEvent[];
/** Used by decision nodes — returns the selected branch label */
branchLabel?: string;
/**
Expand Down Expand Up @@ -1109,6 +1121,14 @@ export interface StepLogEntry {
* partially didn't land instead of a silent success.
*/
warnings?: string[];
/**
* #32861: the structured drop events behind those warnings
* ({@link NodeExecutionResult.droppedFields}). On the step, not in a
* run-scoped side table, so a run that pauses keeps them with the steps it
* persists and the resumed run's terminal answer folds the whole run
* (`dropEventsOf`).
*/
droppedFields?: DroppedFieldsEvent[];
/**
* #1479: structured-region grouping. When a step ran inside a `loop` /
* `parallel` / `try_catch` body region, these tag it with its **immediate**
Expand Down Expand Up @@ -7639,6 +7659,9 @@ export class AutomationEngine implements IAutomationService {
// (a `subflow` roll-up, a runtime test asserting the sweep wrote
// something) never has to re-read the run to learn what it did.
summary,
// #32861 — the values a write step did not store, so the door
// that started the run can tell its caller. Omitted when none.
...withDroppedFields(steps),
};
} catch (err: unknown) {
// [#15788] The run REFUSED — an `end` node declaring
Expand Down
Loading
Loading