Builtin Node Config — Automation Protocol
Config contracts for the remaining flat builtins — the CRUD quartet (get_record / create_record / update_record / delete_record), screen, map.
Config contracts for the remaining flat builtins — the CRUD quartet
(get_record / create_record / update_record / delete_record),
screen, map (#4045), since #14149 assignment's value contract and,
since #14945, the structural end node's outcome (EndConfigSchema, the
one contract here the FLOW PARSE applies rather than an executor).
Sibling of io-node-config.zod.ts (notify / http) and control-flow.zod.ts
(loop / parallel / try_catch).
Provenance — written from the executors, not from the forms
Each schema was derived by reading what the executor actually does with
node.config (service-automation/builtin/crud-nodes.ts,
screen-nodes.ts, map-node.ts), not by transcribing the hand-written
configSchema literal on the node's descriptor. The two artifacts are
reconciled bidirectionally by builtin-node-form-zod-ledger.test.ts in
service-automation; a Zod copied from the form would make that
reconciliation a tautology (#4045).
Writing these against the executors is what surfaced the drift the
reconciliation exists to catch — keys the executors read that no form
offered anywhere (get_record.fields, screen.recordId, the screen
field-item keys options/defaultValue/placeholder, map.indexVariable,
map.input), and the undeclared map.flow alias, which graduated into the
ADR-0087 D2 conversion layer like notify.source before it.
What these schemas are wired to (#4277)
Live execute-time contracts: each executor parse()s its config against
its schema before running (service-automation's parse-config.ts), so
type and required violations refuse the node as a guard. All of these
parse the RAW stored config — their typed slots are strings (or unknown
where values interpolate), so {token} templates pass and resolve at the
executor's existing interpolation points. The value slots are the exception
since #19939: the {token} dialect is retired there (the "value slots"
section below). So are the text slots since #22110: a screen title /
description and an end message render {{ }} holes, and a {token}
there is refused (flow-text-slot-template.ts).
Unknown keys — closed here too, as of #4001 批 9
These contracts used to say "unknown keys are rejected earlier, at
registerFlow() (the tightened #4059 check); the parse here strips them."
The registration walk is still the first and more informative door — it
descends NESTED config against the descriptor's JSON Schema, which is how it
catches fields[0].visibleIf (#3528) and not just top-level typos — but
"some other door is closed" is the exact reasoning #4001 exists to retire:
the sibling of every guard in this campaign turned out to leave the other
doors open, because its author was fixing one bug rather than auditing a
surface. A config reaching parse() without passing registration (tooling
that parses a contract directly, a host composing the engine itself) is no
longer silently trimmed.
The two doors are kept in agreement by builtin-node-form-zod-ledger.test.ts,
which reconciles these key sets against the descriptors' in both directions.
The per-key prescriptions below are the same curation the registration
rejection carries in FLOW_NODE_UNKNOWN_KEY_GUIDANCE — the campaign's
finding is that a bespoke guard's detection generalizes for free the moment
a default flips, while its PROSE does not, so the prose is copied to the new
door rather than left behind at the old one.
assignment is described by its VALUES, not by a key set (#14149): the
canonical assignments map's keys are the author's variable names, and with
no assignments wrapper the TOP-LEVEL config keys are (logic-nodes.ts), so
AssignmentConfigSchema below declares one key and an open catchall and puts
the contract on what a value may be. The form↔Zod ledger test still pins the
descriptor's free-form assignments map as the openness it is; that pin and
this contract describe the same surface from the two sides.
The create_record / update_record fields map carries the same value
contract since #19938 (FlowValueSlotSchema, the "value slots" section):
a field value is a CEL value envelope or a literal — a {token} template is
refused there since #19939 — and the three maps are the expression ledger's
value-role slots.
Deliberately absent:
decision/script/subflow/wait/connector_action— the descriptor-schemaless class (config-schemas.test.ts).waitandconnector_actionkeep their contracts in FlowNodeSchema's sibling blocks (waitEventConfig/connectorConfig); the other three publish executor-derived config contracts inschemaless-node-config.zod.ts(#4278) — separate from this module because they must NOT grow into descriptorconfigSchemas (the forms they describe stay hand-written in objectui, reconciled by a test there).
Source: packages/spec/src/automation/builtin-node-config.zod.ts
TypeScript Usage
import { AssignmentConfigSchema, AssignmentExpressionValueSchema, AssignmentValueSchema, CreateRecordConfigSchema, DeleteRecordConfigSchema, EndConfigSchema, FlowValueSlotSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { AssignmentConfig, AssignmentExpressionValue, AssignmentValue, CreateRecordConfig, DeleteRecordConfig, EndConfig, FlowValueSlot, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
// Validate data
const result = AssignmentConfigSchema.parse(data);AssignmentConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| assignments | Record<string, any> | optional | Variables to set: each key is a variable name, each value a CEL value envelope or a literal |
AssignmentExpressionValue
CEL value envelope { dialect: 'cel', source } — evaluated by the expression engine to the value the slot takes; the whole CEL stdlib (joinNonEmpty, …) is reachable
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| dialect | 'cel' | ✅ | |
| source | string | ✅ | |
| ast | any | optional | |
| meta | { rationale?: string; generatedBy?: string } | optional |
AssignmentValue
Value the variable takes: a CEL value envelope { dialect: 'cel', source } evaluated by the expression engine (the CEL stdlib such as joinNonEmpty is reachable), or a literal written as it is — a {…} template token in a string is refused (the template dialect is retired from value slots; the date macros and $User paths are kept for now)
CreateRecordConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| objectName | string | ✅ | Object to insert into |
| fields | Record<string, any> | optional | Field values to write on the new record: each key is a field name, each value a CEL value envelope or a literal |
| outputVariable | string | optional | Flow variable bound to the created record |
DeleteRecordConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| objectName | string | ✅ | Object to delete from |
| filter | Record<string, any> | optional | Field/value pairs identifying the record(s) to delete |
| multi | boolean | optional | Declare bulk intent: delete every row the filter matches (default false — a predicate delete without it is refused by the engine) |
EndConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| outcome | Enum<'completed' | 'refused'> | optional (default: "completed") | How the run ends when it reaches this node. completed (the default) is the ordinary terminal. refused is a first-class refusal: the run records refused — distinct from failed, a refusal is a successful evaluation that says no — carries the rendered message, is never resumed, and a runner shows the message with Close only: no Submit, no completion toast. |
| message | string | optional | Why the run was refused, as a template rendered at run time exactly like a screen description — {{ }} holes over the flow's variables ({{ record.name }}), so the text names the record. Required when outcome is refused; refused when it is completed — a completion renders nothing, so the key would be a silent no-op. |
FlowValueSlot
A value: a CEL value envelope { dialect: 'cel', source } evaluated by the expression engine (the CEL stdlib such as joinNonEmpty is reachable), or a literal written as it is — a {…} template token in a string is refused (the template dialect is retired from value slots; the date macros and $User paths are kept for now)
GetRecordConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| objectName | string | ✅ | Object to query |
| filter | Record<string, any> | optional | Field/value pairs to match (operator values like {"$ne": null} are preserved) |
| fields | string[] | optional | Field projection — only these fields are read (default: all) |
| limit | number | optional | Max records to return; >1 switches to a multi-record query |
| outputVariable | string | optional | Flow variable the result is bound to |
MapConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| collection | string | any[] | ✅ | Template/variable resolving to the array to process (an inline array is accepted) |
| flowName | string | ✅ | Subflow run for each item — it may pause (e.g. an approval) |
| iteratorVariable | string | optional (default: "item") | Variable holding the current item |
| indexVariable | string | optional | Optional variable holding the current index |
| itemObject | string | optional | When items are records, the object they belong to (exposes each item as the child's record) |
| input | Record<string, any> | optional | Params passed to each item's subflow (interpolated per item) |
| outputVariable | string | optional | Each item's subflow output, collected in order |
ScreenConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| title | string | optional | Heading shown above the screen — a template rendered per run with {{ }} holes over the flow's variables ({{ record.name }}); falls back to the node label when it renders nothing |
| description | string | optional | Body text shown under the heading — a template rendered per run with {{ }} holes ({{ record.name }}) |
| fields | { name: string; label?: string; type?: string; required?: boolean; … }[] | optional | Input fields collected on this screen |
| waitForInput | boolean | optional | Pause to show the screen even with no fields; false forces a server pass-through |
| objectName | string | optional | Render this object's full create/edit form instead of a flat field list |
| idVariable | string | optional | Object form only: variable bound to the saved record's id |
| mode | Enum<'create' | 'edit'> | optional (default: "create") | Object form only: create (default) or edit; 'edit' needs a recordId to name its target |
| recordId | string | optional | Object form only: id of the record to edit (required for mode: 'edit' to be useful) |
| defaults | Record<string, any> | optional | Object form only: prefilled values |
Nested Shape: ScreenConfig.fields[number]
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | ✅ | Field name (the flow variable the value binds to) |
| label | string | optional | Display label |
| type | string | optional | Input type |
| required | boolean | optional | Whether a value is required to submit |
| options | { value: any; label: string }[] | optional | Choices for a select-style field |
| defaultValue | any | optional | Prefilled value (interpolates {token} templates) |
| placeholder | string | optional | Input placeholder text |
| visibleWhen | string | optional | CEL predicate controlling visibility, evaluated client-side |
| min | number | optional | Minimum accepted value (numeric fields); enforced on resume |
| max | number | optional | Maximum accepted value (numeric fields); enforced on resume |
| inlineHelpText | string | optional | Help text displayed below the field |
| reference | string | optional | Target object name (snake_case) whose records a type: 'lookup' field picks from; REQUIRED when type is lookup |
ScreenFieldConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | ✅ | Field name (the flow variable the value binds to) |
| label | string | optional | Display label |
| type | string | optional | Input type |
| required | boolean | optional | Whether a value is required to submit |
| options | { value: any; label: string }[] | optional | Choices for a select-style field |
| defaultValue | any | optional | Prefilled value (interpolates {token} templates) |
| placeholder | string | optional | Input placeholder text |
| visibleWhen | string | optional | CEL predicate controlling visibility, evaluated client-side |
| min | number | optional | Minimum accepted value (numeric fields); enforced on resume |
| max | number | optional | Maximum accepted value (numeric fields); enforced on resume |
| inlineHelpText | string | optional | Help text displayed below the field |
| reference | string | optional | Target object name (snake_case) whose records a type: 'lookup' field picks from; REQUIRED when type is lookup |
Nested Shape: ScreenFieldConfig.options[number]
| Property | Type | Required | Description |
|---|---|---|---|
| value | any | ✅ | Stored value |
| label | string | ✅ | Display label |
UpdateRecordConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| objectName | string | ✅ | Object to update |
| filter | Record<string, any> | optional | Field/value pairs identifying the record(s) to update |
| fields | Record<string, any> | optional | Field values to write: each key is a field name, each value a CEL value envelope or a literal |
| multi | boolean | optional | Declare bulk intent: update every row the filter matches (default false — a predicate update without it is refused by the engine) |