ObjectStackObjectStack

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). wait and connector_action keep their contracts in FlowNodeSchema's sibling blocks (waitEventConfig / connectorConfig); the other three publish executor-derived config contracts in schemaless-node-config.zod.ts (#4278) — separate from this module because they must NOT grow into descriptor configSchemas (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

PropertyTypeRequiredDescription
assignmentsRecord<string, any>optionalVariables 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

PropertyTypeRequiredDescription
dialect'cel'✅
sourcestring✅
astanyoptional
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

PropertyTypeRequiredDescription
objectNamestring✅Object to insert into
fieldsRecord<string, any>optionalField values to write on the new record: each key is a field name, each value a CEL value envelope or a literal
outputVariablestringoptionalFlow variable bound to the created record

DeleteRecordConfig

Properties

PropertyTypeRequiredDescription
objectNamestring✅Object to delete from
filterRecord<string, any>optionalField/value pairs identifying the record(s) to delete
multibooleanoptionalDeclare bulk intent: delete every row the filter matches (default false — a predicate delete without it is refused by the engine)

EndConfig

Properties

PropertyTypeRequiredDescription
outcomeEnum<'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.
messagestringoptionalWhy 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

PropertyTypeRequiredDescription
objectNamestring✅Object to query
filterRecord<string, any>optionalField/value pairs to match (operator values like {"$ne": null} are preserved)
fieldsstring[]optionalField projection — only these fields are read (default: all)
limitnumberoptionalMax records to return; >1 switches to a multi-record query
outputVariablestringoptionalFlow variable the result is bound to

MapConfig

Properties

PropertyTypeRequiredDescription
collectionstring | any[]✅Template/variable resolving to the array to process (an inline array is accepted)
flowNamestring✅Subflow run for each item — it may pause (e.g. an approval)
iteratorVariablestringoptional (default: "item")Variable holding the current item
indexVariablestringoptionalOptional variable holding the current index
itemObjectstringoptionalWhen items are records, the object they belong to (exposes each item as the child's record)
inputRecord<string, any>optionalParams passed to each item's subflow (interpolated per item)
outputVariablestringoptionalEach item's subflow output, collected in order

ScreenConfig

Properties

PropertyTypeRequiredDescription
titlestringoptionalHeading 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
descriptionstringoptionalBody text shown under the heading — a template rendered per run with {{ }} holes ({{ record.name }})
fields{ name: string; label?: string; type?: string; required?: boolean; … }[]optionalInput fields collected on this screen
waitForInputbooleanoptionalPause to show the screen even with no fields; false forces a server pass-through
objectNamestringoptionalRender this object's full create/edit form instead of a flat field list
idVariablestringoptionalObject form only: variable bound to the saved record's id
modeEnum<'create' | 'edit'>optional (default: "create")Object form only: create (default) or edit; 'edit' needs a recordId to name its target
recordIdstringoptionalObject form only: id of the record to edit (required for mode: 'edit' to be useful)
defaultsRecord<string, any>optionalObject form only: prefilled values

Nested Shape: ScreenConfig.fields[number]

PropertyTypeRequiredDescription
namestring✅Field name (the flow variable the value binds to)
labelstringoptionalDisplay label
typestringoptionalInput type
requiredbooleanoptionalWhether a value is required to submit
options{ value: any; label: string }[]optionalChoices for a select-style field
defaultValueanyoptionalPrefilled value (interpolates {token} templates)
placeholderstringoptionalInput placeholder text
visibleWhenstringoptionalCEL predicate controlling visibility, evaluated client-side
minnumberoptionalMinimum accepted value (numeric fields); enforced on resume
maxnumberoptionalMaximum accepted value (numeric fields); enforced on resume
inlineHelpTextstringoptionalHelp text displayed below the field
referencestringoptionalTarget object name (snake_case) whose records a type: 'lookup' field picks from; REQUIRED when type is lookup

ScreenFieldConfig

Properties

PropertyTypeRequiredDescription
namestring✅Field name (the flow variable the value binds to)
labelstringoptionalDisplay label
typestringoptionalInput type
requiredbooleanoptionalWhether a value is required to submit
options{ value: any; label: string }[]optionalChoices for a select-style field
defaultValueanyoptionalPrefilled value (interpolates {token} templates)
placeholderstringoptionalInput placeholder text
visibleWhenstringoptionalCEL predicate controlling visibility, evaluated client-side
minnumberoptionalMinimum accepted value (numeric fields); enforced on resume
maxnumberoptionalMaximum accepted value (numeric fields); enforced on resume
inlineHelpTextstringoptionalHelp text displayed below the field
referencestringoptionalTarget object name (snake_case) whose records a type: 'lookup' field picks from; REQUIRED when type is lookup

Nested Shape: ScreenFieldConfig.options[number]

PropertyTypeRequiredDescription
valueany✅Stored value
labelstring✅Display label

UpdateRecordConfig

Properties

PropertyTypeRequiredDescription
objectNamestring✅Object to update
filterRecord<string, any>optionalField/value pairs identifying the record(s) to update
fieldsRecord<string, any>optionalField values to write: each key is a field name, each value a CEL value envelope or a literal
multibooleanoptionalDeclare bulk intent: update every row the filter matches (default false — a predicate update without it is refused by the engine)

On this page