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
37 changes: 37 additions & 0 deletions .changeset/19938-fields-value-slot-cel-envelope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@objectstack/spec": minor
"@objectstack/service-automation": minor
"@objectstack/lint": minor
---

`create_record` / `update_record` field values accept the CEL value envelope, declared and evaluated together.

A value in a `create_record` or `update_record` node's `fields` map may now be a CEL value envelope, `{ dialect: 'cel', source: '…' }`, with the same shape and dialect rules the `assignment` node's `assignments` map already has. The envelope is evaluated by the expression engine that flow conditions use, so the whole CEL stdlib is reachable from a field value, and the result is written with its type kept:

```ts
fields: {
subject: 'Quote for {account.name}', // `{token}` template — unchanged
total: { dialect: 'cel', source: 'round(amount * 100.0) / 100.0' }, // CEL, evaluated to the value written
}
```

Clause-②: yes (widening) — a published authoring slot's accept set grows (a valid envelope in `fields.*` is newly evaluated), and the one newly refused shape is the edge the `assignments` map accepted when it gained the envelope: a malformed one.

**What newly passes.** A valid CEL value envelope as a top-level `fields` value, on both nodes. Before this release the executor wrote such an object into the record verbatim: a text or JSON column stored `{"dialect":"cel","source":"…"}` and the run reported success, and a number column was refused by the data engine.

**What newly refuses.** A top-level `fields` value that is a plain object with a string `dialect` key and is NOT a valid CEL value envelope. That covers a missing, empty or whitespace-only `source`, an `ast` with no `source`, a `template` or `cron` dialect, and a `source` that does not parse as CEL. Every door refuses it, located at `config.fields.<field>`: `AutomationEngine.registerFlow` refuses the flow, `objectstack validate` reports an `expression-invalid` error, the runtime publish gate answers `422 INVALID_METADATA`, and the node's execute-time contract parse refuses it. Such an object used to be written as data.

**The rule for nested and literal values.** Only the top-level value of each field is judged. An object nested inside a JSON value or an array is data, whatever keys it carries, and strings inside it still interpolate. A plain string is always a `{token}` template with its existing meaning, and every other literal is written as before. A JSON column whose intended literal value is itself an object with a string `dialect` key is now read as an envelope. To write such an object as data, bind it to a flow variable and write `'{thatVariable}'` (a sole token keeps its type). Measured: no flow in this repository or in HotCRM writes an envelope-shaped object into `fields`.

**The refusal sentence is slot-neutral.** A refused field value used to be told it was "an assignment value". The sentence every value-slot refusal leads with is now `VALUE_ENVELOPE_REFUSAL`: "A value carrying a `dialect` key is read as an expression envelope, and this one is not a valid CEL value envelope." The published `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` is kept and is the same string, so code that matches on the constant keeps matching. Code that matched the old literal text ("An assignment value carrying…") does not.

**New in `@objectstack/spec/automation`** (5 exports, 0 removed):

- `VALUE_ENVELOPE_REFUSAL`, the slot-neutral refusal sentence.
- `FlowValueSlotSchema` / `FlowValueSlot` / `FlowValueSlotParsed`, the value contract every value slot shares (`AssignmentValueSchema` is the same rule under the assignment map's description).
- `resolveFlowNodeValueSlots(nodeType, config)`, which returns every authored value in the ledger's value slots, strings included.
- The expression ledger `FLOW_NODE_EXPRESSION_PATHS` has two new rows, `create_record.fields.*` and `update_record.fields.*` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries both CRUD contracts.

**Author-time hint (`@objectstack/lint`).** `objectstack validate` warns when a value slot holds a `{…}` template expression, meaning arithmetic or a call to `round` / `floor` / `ceil` / `abs` / `min` / `max`, and points it at the envelope. The warning never fails a build, and the template form keeps working unchanged. Plain references, the `NOW()` / `TODAY()` macros and `$User` paths are not hinted. CEL's `now()` / `today()` are timestamps rather than the strings those macros write, and the flow's CEL scope binds no user.

**Corrected guidance: `/ 100.0`, not `/ 100`.** The template dialect's `round()` arity refusal used to call `round(x * 100) / 100` the CEL authoring pattern. In CEL that expression truncates: `round()` returns an int, and int / int is integer division, so `x = 1234.5678` gives `1234` instead of `1234.57`. The refusal now prescribes `round(x * 100) / 100.0`, which is correct in both dialects. In the template dialect `/ 100` and `/ 100.0` give the same value.
51 changes: 35 additions & 16 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -199,28 +199,35 @@ or missing-`required` violation (#4277). A node type that publishes no
A value's **shape** selects its form — there is no mode key. A plain string is
always `{token}` interpolation (a bare `a + b` is the literal text `a + b`, not
CEL); an object that names a `dialect` is an expression envelope and must be a
valid `cel` one — a missing, empty or non-string `source`, or a `template` /
`cron` dialect, is refused at the variable's path. Numbers, booleans, arrays
and plain objects are assigned as literals. A later `notify` node renders the
variable as any other: `message: '{digest}'`, and it renders the **evaluated**
value.
valid `cel` one — a missing, empty, whitespace-only or non-string `source`, an
`ast` with no `source`, or a `template` / `cron` dialect, is refused at the
variable's path. Numbers, booleans, arrays and plain objects are assigned as
literals. A later `notify` node renders the variable as any other:
`message: '{digest}'`, and it renders the **evaluated** value.

The same rules hold for the field values of `create_record` / `update_record`
(below): the `assignments` map and the `fields` map are the flow's **value
slots**, and each accepts a CEL value envelope beside `{token}` templates and
literals. Only a slot's top-level value is judged — an object nested inside a
JSON value or an array is data, whatever keys it carries.

<Callout type="info" title="Where a malformed envelope is refused">

A malformed envelope never reaches run time silently: the same refusal runs at
`objectstack validate` (a located finding naming the variable), at the runtime
publish gate a Studio / REST / MCP flow write goes through, and at
`registerFlow`, which refuses to register the flow. All three ask the same two
questions in the same order — is it a valid `cel` envelope
(`AssignmentValueSchema`), and does its source parse as CEL
`objectstack validate` (a located finding naming the variable or field), at the
runtime publish gate a Studio / REST / MCP flow write goes through (a `422`),
and at `registerFlow`, which refuses to register the flow — in every value
slot. All three ask the same two questions in the same order — is it a valid
`cel` envelope (`FlowValueSlotSchema`), and does its source parse as CEL
(`validateExpression`) — so a flow that registers is a flow whose envelopes
those two validators accept.

Two shapes sit outside what either validator can judge and fault loudly at run
time instead of assigning a value: an `ast`-only envelope (no `source` — the
CEL engine evaluates `source`), and a whitespace-only `source`, which passes
`min(1)` and reads as "not authored" to the validator while the engine parses
it untrimmed. Both are tracked in [#15430].
That includes the two shapes the persistence contract alone would accept but no
engine can run — an `ast`-only envelope (the CEL engine evaluates `source`) and
a whitespace-only `source` — refused at authoring since [#15430]. What remains
for run time is an envelope that parses but cannot evaluate on the live values
(an absent variable, say): it fails the run with its source attached, and
nothing is assigned or written in its place.

[#15430]: https://github.com/objectstack-ai/objectstack/issues/15430

Expand All @@ -239,6 +246,9 @@ it untrimmed. Both are tracked in [#15430].
title: 'Follow up on {record.name}',
assignee: '{record.owner}',
due_date: '{TODAY() + 7}', // braces required — without them this writes the literal text
// CEL value envelope — evaluated to the value written, same rules as an
// assignment value. `100.0`, not `100`: CEL divides two integers as integers.
estimate: { dialect: 'cel', source: 'round(record.amount * 0.15 * 100.0) / 100.0' },
},
},
}
Expand Down Expand Up @@ -1784,7 +1794,16 @@ means the same thing inside braces.
| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*` |
| Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above |
| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, and `vars.*` |
| Field values in `create_record` / `update_record` | **Interpolation** (braces required) | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, `{TODAY()}`, `{TODAY() + 90}` (whole days), and the CEL-mirrored numeric functions `round`, `floor`, `ceil`, `abs`, `min`, `max` (#11060) — `round` is **integer-only**, exactly like CEL's (there is no `round(x, 2)`); for N decimals write the CEL idiom `{round(x * 100) / 100}` (scale 2) |
| Field values in `create_record` / `update_record` | **Interpolation** (braces required) | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, `{TODAY()}`, `{TODAY() + 90}` (whole days), and the CEL-mirrored numeric functions `round`, `floor`, `ceil`, `abs`, `min`, `max` (#11060) — `round` is **integer-only**, exactly like CEL's (there is no `round(x, 2)`); for N decimals write `{round(x * 100) / 100.0}` (scale 2). Keep the decimal point: in CEL `round()` returns an int and `int / int` is integer division, so `round(x * 100) / 100` drops the decimals there — `/ 100.0` is right in both dialects |
| Field values and assignment values, as a **CEL value envelope** | **CEL** (in an envelope) | `{ dialect: 'cel', source: 'round(price * 100.0) / 100.0' }` | flow variables by name, and `vars.*` — the whole CEL stdlib (`joinNonEmpty`, …) |

A value slot takes either form, chosen by shape: a string is interpolation, an
object naming a `dialect` is a CEL envelope. The template form keeps working
unchanged; `objectstack validate` points a template **expression** — arithmetic
or one of the six functions inside braces — at the envelope with a warning,
never an error. Plain references (`{record.name}`), the date macros and
`{$User.*}` are left alone: CEL's `now()` / `today()` are timestamps, not the
strings the macros write, and the flow's CEL scope binds no user.

<Callout type="warn">
**The failure modes to memorize:**
Expand Down
22 changes: 17 additions & 5 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ 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 may be a CEL value envelope beside a `{token}` template or a
literal, 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
Expand All @@ -86,8 +91,8 @@ Deliberately absent:
## TypeScript Usage

```typescript
import { AssignmentConfigSchema, AssignmentExpressionValueSchema, AssignmentValueSchema, CreateRecordConfigSchema, DeleteRecordConfigSchema, EndConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
import type { AssignmentConfig, AssignmentExpressionValue, AssignmentValue, CreateRecordConfig, DeleteRecordConfig, EndConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
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);
Expand All @@ -108,7 +113,7 @@ const result = AssignmentConfigSchema.parse(data);

## AssignmentExpressionValue

CEL value envelope `{ dialect: 'cel', source }` — evaluated by the expression engine to the value the variable takes; the whole CEL stdlib (`joinNonEmpty`, …) is reachable
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

Expand Down Expand Up @@ -136,7 +141,7 @@ Value the variable takes: a string (`{token}` flow interpolation — a sole toke
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **objectName** | `string` | ✅ | Object to insert into |
| **fields** | `Record<string, any>` | optional | Field values to write on the new record |
| **fields** | `Record<string, any>` | optional | Field values to write on the new record: each key is a field name, each value a `{token}` template, a CEL value envelope, or a literal |
| **outputVariable** | `string` | optional | Flow variable bound to the created record |


Expand Down Expand Up @@ -165,6 +170,13 @@ Value the variable takes: a string (`{token}` flow interpolation — a sole toke
| **message** | `string` | optional | Why the run was refused, as a `{token}` template interpolated at run time exactly like a screen `description` (`{record.name}` etc.), 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 string (`{token}` flow interpolation — a sole token keeps its type), a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expression engine (the CEL stdlib such as `joinNonEmpty` is reachable), or any other literal


---

## GetRecordConfig
Expand Down Expand Up @@ -272,7 +284,7 @@ Value the variable takes: a string (`{token}` flow interpolation — a sole toke
| :--- | :--- | :--- | :--- |
| **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 |
| **fields** | `Record<string, any>` | optional | Field values to write: each key is a field name, each value a `{token}` template, 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) |


Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Protocol Reference
description: Every schema published by @objectstack/spec — 1522 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1523 schemas across 14 protocol modules
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand All @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with
| :--- | ---: | ---: | :--- |
| [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
| [API Protocol](/docs/references/api) | 32 | 429 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 14 | 74 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Automation Protocol](/docs/references/automation) | 14 | 75 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Data Protocol](/docs/references/data) | 29 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
| [Integration Protocol](/docs/references/integration) | 1 | 24 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. |
Expand All @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 159 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **196** | **1522** | 14 protocol modules |
| **Total** | **196** | **1523** | 14 protocol modules |

---

Expand Down Expand Up @@ -105,15 +105,15 @@ REST contracts, endpoints, routing, realtime, batch, discovery.

## Automation Protocol

**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 74 schemas**
**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 75 schemas**

Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records.

| File | Schemas |
| :--- | :--- |
| [`approval.zod.ts`](/docs/references/automation/approval) | `ApprovalDecision`, `ApprovalEscalation`, `ApprovalNodeApprover`, `ApprovalNodeConfig`, `ApproverType`, `DecisionOutputDef` |
| [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `EndConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `EndConfig`, `FlowValueSlot`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
| [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` |
| [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary` |
| [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` |
Expand Down
Loading
Loading