diff --git a/.changeset/19938-fields-value-slot-cel-envelope.md b/.changeset/19938-fields-value-slot-cel-envelope.md new file mode 100644 index 00000000000..d9c538951fd --- /dev/null +++ b/.changeset/19938-fields-value-slot-cel-envelope.md @@ -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.`: `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. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index c3e44673841..a2ec4928d0f 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -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. 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 @@ -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' }, }, }, } @@ -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. **The failure modes to memorize:** diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index e531779ffe6..094d6f08af3 100644 --- a/content/docs/references/automation/builtin-node-config.mdx +++ b/content/docs/references/automation/builtin-node-config.mdx @@ -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 @@ -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); @@ -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 @@ -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` | optional | Field values to write on the new record | +| **fields** | `Record` | 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 | @@ -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 @@ -272,7 +284,7 @@ Value the variable takes: a string (`{token}` flow interpolation — a sole toke | :--- | :--- | :--- | :--- | | **objectName** | `string` | ✅ | Object to update | | **filter** | `Record` | optional | Field/value pairs identifying the record(s) to update | -| **fields** | `Record` | optional | Field values to write | +| **fields** | `Record` | 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) | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index c1be0f5264b..fbd5465c59f 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -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/. */} @@ -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. | @@ -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 | --- @@ -105,7 +105,7 @@ 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. @@ -113,7 +113,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu | :--- | :--- | | [`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` | diff --git a/packages/lint/src/validate-expressions.fields-value-slot.test.ts b/packages/lint/src/validate-expressions.fields-value-slot.test.ts new file mode 100644 index 00000000000..cfe760059a5 --- /dev/null +++ b/packages/lint/src/validate-expressions.fields-value-slot.test.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `create_record` / `update_record` `fields.*` — the `objectstack validate` half + * of the value slot #19938 declares (the contract half of #11182 ruling D). + * + * Driven through the REGISTRY, the way `os validate` runs it: the stack is + * normalized, parsed by `ObjectStackDefinitionSchema`, and handed to + * `runAuthoringRules('validate', …)`. A pin on `validateStackExpressions` + * alone would stay green through a registry adapter that dropped or re-graded + * the finding. + * + * Two halves: + * + * 1. **Refusal** — a malformed envelope in `fields.*` is a located `error` + * under the rule id `expression-invalid`, led by the slot-neutral + * `VALUE_ENVELOPE_REFUSAL` (never "an assignment value"), the same verdict + * `registerFlow` throws on. A valid envelope, a `{token}` template and a + * literal are clean. + * 2. **The hint** (ruling D point 1) — a `{…}` template EXPRESSION in any + * `value` slot is pointed at the envelope, at `warning` only, with the one + * conversion trap (`/ 100` → `/ 100.0`) stated. Plain references, date + * macros, `$User` paths and non-value slots get nothing. + */ + +import { describe, expect, it } from 'vitest'; +import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; +import { ASSIGNMENT_VALUE_ENVELOPE_REFUSAL, VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/automation'; +import { EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec'; +import { runAuthoringRules, splitBySeverity, EXPRESSION_INVALID } from './authoring-rules.js'; + +type NodeType = 'create_record' | 'update_record' | 'assignment'; + +/** One flow, one writing node — `config` is the node's whole config. */ +function stackWith(nodeType: NodeType, config: Record) { + return { + objects: [{ + name: 'quote', + label: 'Quote', + fields: { total: { type: 'number', label: 'Total' }, subject: { type: 'text', label: 'Subject' } }, + }], + flows: [{ + name: 'price_quote', + label: 'Price Quote', + type: 'autolaunched', + variables: [{ name: 'price', type: 'number', isInput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'w', type: nodeType, label: 'Write', config }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'w' }, + { id: 'e2', source: 'w', target: 'end' }, + ], + }], + }; +} + +/** `os validate`'s own sequence, minus the file loader. */ +function validate(nodeType: NodeType, config: Record) { + const normalized = normalizeStackInput(stackWith(nodeType, config) as Record); + const parsed = ObjectStackDefinitionSchema.parse(normalized); + const findings = runAuthoringRules('validate', { + normalized: normalized as Record, + parsed: parsed as Record, + }); + // Only what this node's value slots produce — the rest of the stack is scaffolding. + return findings.filter((f) => f.rule === EXPRESSION_INVALID && f.where.includes("node 'w'")); +} + +const crud = (nodeType: 'create_record' | 'update_record', fields: Record) => + nodeType === 'create_record' + ? { objectName: 'quote', fields } + : { objectName: 'quote', filter: { id: '{quoteId}' }, fields }; + +const NODE_TYPES = ['create_record', 'update_record'] as const; + +describe('`fields.*` value slot — the malformed envelope is a located error at `os validate` (#19938)', () => { + it.each(NODE_TYPES.flatMap((t) => [ + [t, 'no `source`', { dialect: 'cel' }, EVALUATED_EXPRESSION_SOURCE_REQUIRED], + [t, 'a whitespace-only `source`', { dialect: 'cel', source: ' ' }, EVALUATED_EXPRESSION_SOURCE_REQUIRED], + [t, 'a `template` dialect', { dialect: 'template', source: 'Hi {name}' }, 'only the `cel` dialect'], + [t, 'CEL that does not parse', { dialect: 'cel', source: 'price *' }, ''], + [t, 'an unknown function', { dialect: 'cel', source: 'nosuchfn(price)' }, ''], + ] as const))('%s: %s — rule `expression-invalid`, severity `error`, at `config.fields.total`', (nodeType, _what, envelope, detail) => { + const findings = validate(nodeType, crud(nodeType, { subject: 'Quote {price}', total: envelope })); + expect(findings).toHaveLength(1); + const [f] = findings; + // The envelope a gate reads: the rule id and the severity. + expect(f!.rule).toBe(EXPRESSION_INVALID); + expect(f!.severity).toBe('error'); + expect(f!.where).toContain(`node 'w' (${nodeType}) ${nodeType} field value at config.fields.total`); + // The rule before the detail — the slot-neutral published sentence, and a + // FIELD value is never told it is an assignment. + expect(f!.message.startsWith(VALUE_ENVELOPE_REFUSAL)).toBe(true); + expect(f!.message).not.toMatch(/assignment/i); + if (detail) expect(f!.message).toContain(detail); + // It gates: `os validate` exits non-zero on it. + expect(splitBySeverity(findings).errors).toHaveLength(1); + }); + + it.each(NODE_TYPES)('%s: a valid envelope, `{token}` templates and literals are clean', (nodeType) => { + expect(validate(nodeType, crud(nodeType, { + total: { dialect: 'cel', source: 'round(price * 100) / 100.0' }, + subject: 'Quote for {price}', + owner: '{$User.Id}', + due: '{TODAY() + 7}', + n: 3, ok: true, nothing: null, + payload: { nested: { dialect: 'cel' } }, // a nested envelope is data + }))).toEqual([]); + }); + + it('the assignment slot answers with the SAME sentence — one refusal notion for every value slot', () => { + expect(ASSIGNMENT_VALUE_ENVELOPE_REFUSAL).toBe(VALUE_ENVELOPE_REFUSAL); + const [f] = validate('assignment', { assignments: { total: { dialect: 'cel' } } }); + expect(f!.severity).toBe('error'); + expect(f!.message.startsWith(VALUE_ENVELOPE_REFUSAL)).toBe(true); + }); +}); + +describe('the author-time hint — a `{…}` template expression in a value slot points at the envelope (#11182 ruling D)', () => { + const HINTED: ReadonlyArray<[NodeType, Record, string]> = [ + ['create_record', crud('create_record', { total: '{round(price * 100) / 100}' }), 'config.fields.total'], + ['update_record', crud('update_record', { total: '{price * 2}' }), 'config.fields.total'], + ['create_record', crud('create_record', { subject: 'Total: {max(price, 10)}' }), 'config.fields.subject'], + ['assignment', { assignments: { total: '{floor(price)}' } }, 'config.assignments.total'], + ]; + + it.each(HINTED)('%s: warns at the slot, never errors — the template form keeps its meaning', (nodeType, config, at) => { + const findings = validate(nodeType, config); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f!.severity).toBe('warning'); + expect(f!.rule).toBe(EXPRESSION_INVALID); + expect(f!.where).toContain(at); + expect(f!.message).toContain("{ dialect: 'cel', source: '…' }"); + // The one conversion trap, stated where the author reads the hint. + expect(f!.message).toContain('`round(x * 100) / 100.0`'); + // Advisory: `os validate` still passes. + expect(splitBySeverity(findings).errors).toEqual([]); + }); + + it.each([ + ['a plain reference — CEL adds nothing, and an absent key would start faulting', '{record.name}'], + ['text with a reference hole', 'Follow up on {record.name}'], + ['a date macro — CEL has no string form of a Timestamp', '{NOW()}'], + ['a date macro with an offset', '{TODAY() + 7}'], + ['a `$User` path — the flow CEL scope binds no user', '{$User.Id}'], + ['an unknown function — a run-time refusal, not a candidate to move', '{ROUND(price)}'], + ['a string literal token', '{"fixed"}'], + ['plain text', 'approved'], + ])('says nothing for %s', (_why, value) => { + for (const nodeType of NODE_TYPES) { + expect(validate(nodeType, crud(nodeType, { v: value })), `${nodeType}: ${value}`).toEqual([]); + } + expect(validate('assignment', { assignments: { v: value } })).toEqual([]); + }); + + it('says nothing outside a value slot — `update_record.filter` is a match map, not a value', () => { + expect(validate('update_record', { objectName: 'quote', filter: { total: '{round(price)}' }, fields: { subject: 'x' } })) + .toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 8a4988fab18..bc0c18046fd 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -3016,6 +3016,10 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t // this file is a local named `scope`; `graph.scope` is a KEY read off the // tabled `graph` receiver, so the metadata guard loses no coverage here. 'scope', + // [#19938] The same import-specifier artefact, of + // `'./flow-template-grammar.js'` (`grammar.j…`): nothing in this file is + // a local named `grammar`. + 'grammar', ]); expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]); }); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 35b2b282584..a22c05d6759 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -89,15 +89,17 @@ import { collectFlowGraphs, predicateSlotRefusal, resolveFlowNodeExpressions, + resolveFlowNodeValueSlots, structuralConditionRefusal, } from '@objectstack/spec/automation'; // [#15137] The `value`-role half. Same two published primitives the engine // composes at `registerFlow` (`AutomationEngine.valueEnvelopeRefusals`), in the -// same order: the SHAPE rule lives in the spec's `AssignmentValueSchema` (it -// refuses a non-`cel` dialect and the source-less `{ dialect: 'cel' }` that -// `validateExpression` reads as "not authored"), the CEL rule in -// `validateExpression('value', …)`. Neither refusal string is spelled here. -import { AssignmentValueSchema, ASSIGNMENT_VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/automation'; +// same order: the SHAPE rule lives in the spec's `FlowValueSlotSchema` (the +// slot-neutral name since #19938 — it refuses a non-`cel` dialect and the +// source-less `{ dialect: 'cel' }` that `validateExpression` reads as "not +// authored"), the CEL rule in `validateExpression('value', …)`. Neither +// refusal string is spelled here. +import { FlowValueSlotSchema, VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/automation'; import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automation'; // [#17495] The blank-source half of the structural-condition refusal, imported // rather than restated: this is the EDGE door's own rule @@ -114,6 +116,68 @@ import { injectedColumnsFor, unprovisionedInjectedColumnsFor } from './system-fi import { findUnguardedNullableOperands, nullGuardMessage } from './validate-null-guards.js'; import type { NullGuardOutcome } from './validate-null-guards.js'; import { recordsOf } from './object-graph.js'; +import { classifyFlowTemplateToken, FLOW_TEMPLATE_VALUE_FUNCTIONS, SAFE_EXPRESSION_RE } from './flow-template-grammar.js'; + +/** + * The author-time hint of #11182 ruling D: a `value` slot (`assignments.*`, + * `create_record` / `update_record` `fields.*`) accepts a CEL value envelope, + * so a `{…}` template EXPRESSION authored there is pointed at it — at + * `warning`, never more: the template dialect keeps its 17.x meaning and no + * spelling is refused or rewritten. + * + * Which tokens, and why only those — the hint must never steer an author + * toward metadata the runtime honours but that makes the value worse: + * + * - a template EXPRESSION — arithmetic, a comparison, or a call to one of the + * CEL-mirrored six (`round` / `floor` / `ceil` / `abs` / `min` / `max`) — + * is where the two vocabularies overlap and CEL is a strict superset (the + * whole stdlib). The one conversion trap is stated in the hint itself: CEL + * divides two integers as integers, so `/ 100` must become `/ 100.0`. + * - ⛔ NOT a plain `{var}` / `{var.path}` reference: CEL adds nothing to it, + * and an absent key flips from `undefined` to a fault — a behaviour change + * the hint would be recommending in the dark. + * - ⛔ NOT `{NOW()}` / `{TODAY() ± N}`: CEL's `now()` / `today()` are + * Timestamps, not the ISO strings the macros produce, and CEL has no + * `string(timestamp)` to recover them — the string form is the v18 + * carrier's to add. + * - ⛔ NOT `{$User.*}`: the flow CEL scope binds no user. + * + * The token grammar is the lint package's MIRROR of the template evaluator + * (`flow-template-grammar.ts`, drift-pinned against `template.ts`), consulted + * in the evaluator's own dispatch order — never a second reading of it. + */ +const TEMPLATE_TOKEN_RE = /\{([^{}]+)\}/g; +const TEMPLATE_EXPRESSION_OPERATOR_RE = /[+\-*/%<>=!&|?]/; +const TEMPLATE_VALUE_CALL_RE = new RegExp(`\\b(?:${FLOW_TEMPLATE_VALUE_FUNCTIONS.join('|')})\\s*\\(`); + +/** The first `{…}` token in `value` that is a template EXPRESSION (see above), or `undefined`. */ +function templateExpressionToken(value: string): string | undefined { + // A global regex carries `lastIndex` between calls, so the scan starts from + // zero every time; the loop is synchronous, so nothing interleaves. + TEMPLATE_TOKEN_RE.lastIndex = 0; + for (let match = TEMPLATE_TOKEN_RE.exec(value); match !== null; match = TEMPLATE_TOKEN_RE.exec(value)) { + const inner = match[1]!.trim(); + // A date macro, a `$User` path, a variable path or an unknown call is + // classified away here, in the evaluator's own order. + if (classifyFlowTemplateToken(inner).kind !== 'unresolvable-shape') continue; + // Outside the evaluator's arithmetic character set the token resolves to + // nothing at all — junk, not an expression to move. + if (!SAFE_EXPRESSION_RE.test(inner)) continue; + if (TEMPLATE_EXPRESSION_OPERATOR_RE.test(inner) || TEMPLATE_VALUE_CALL_RE.test(inner)) return match[0]; + } + return undefined; +} + +/** The hint's text — the conversion trap stated where the author reads it. */ +function templateExpressionEnvelopeHint(token: string): string { + return ( + `\`${token}\` is a \`{…}\` template-dialect expression. This slot also accepts a CEL value envelope — ` + + "`{ dialect: 'cel', source: '…' }` — evaluated by the engine flow conditions use, with the whole CEL stdlib; " + + 'the template form keeps working unchanged. When moving arithmetic to CEL, give a division a decimal operand: ' + + 'CEL divides two integers as integers, so `round(x * 100) / 100` drops the decimals there — write ' + + '`round(x * 100) / 100.0`.' + ); +} export interface ExprIssue { where: string; @@ -1360,9 +1424,9 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { }; /** - * A declared `value` slot (#15137) — today the `assignment` node's - * `assignments.*`, the one slot whose job is to compute a value into a - * variable. + * A declared `value` slot (#15137) — the `assignment` node's + * `assignments.*` and, since #19938, the `create_record` / `update_record` + * `fields.*`: the slots whose job is to compute a value. * * The resolver emits ONLY envelope-shaped objects for this role, so anything * that arrives is an author declaring an expression. `error`, matching the @@ -1375,7 +1439,7 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { // `celSourceOf` — not a second read of `.source`: the same helper every // other slot in this file locates its finding with. const source = celSourceOf(raw) ?? ''; - const shape = AssignmentValueSchema.safeParse(raw); + const shape = FlowValueSlotSchema.safeParse(raw); if (!shape.success) { // Already prefixed by the spec's own refinement — do not say it twice. for (const issue of shape.error.issues) issues.push({ where, message: issue.message, source, severity: 'error' }); @@ -1383,7 +1447,7 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { } const res = validateExpression('value', raw as { dialect?: string; source?: string }); for (const e of res.errors) { - issues.push({ where, message: `${ASSIGNMENT_VALUE_ENVELOPE_REFUSAL} ${e.message}`, source: e.source, severity: 'error' }); + issues.push({ where, message: `${VALUE_ENVELOPE_REFUSAL} ${e.message}`, source: e.source, severity: 'error' }); } for (const w of res.warnings) { issues.push({ where, message: w.message, source: w.source, severity: 'warning' }); @@ -1594,6 +1658,25 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { // (the 2026-09-01 option-C ruling's letter). warnShadowedFieldReads(slotWhere, found.value); } + // [#11182 ruling D] The author-time hint: a `{…}` template EXPRESSION in + // a `value` slot is pointed at the CEL value envelope that slot also + // accepts — `warning` only, the template form keeps its meaning. The + // slots come from the ledger's own walk (`resolveFlowNodeValueSlots`), + // never from a path list re-spelled here; which tokens qualify, and + // why only those, is `templateExpressionToken`'s docblock. + // `found` — the same resolver-result shape the declared-slot loop above + // reads (`entry` / `path` / `value`, the resolver's own keys). + for (const found of resolveFlowNodeValueSlots(nodeType, cfg)) { + if (typeof found.value !== 'string') continue; + const token = templateExpressionToken(found.value); + if (token === undefined) continue; + issues.push({ + where: `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`, + message: templateExpressionEnvelopeHint(token), + source: found.value, + severity: 'warning', + }); + } // #1870 — a `script` node must name a callable, and since #4343 that is // the whole of what the node does: `config.function`. A node without one // is a silent no-op that otherwise passes build. (Function *existence* diff --git a/packages/services/service-automation/src/builtin/config-expression-ledger.test.ts b/packages/services/service-automation/src/builtin/config-expression-ledger.test.ts index b8d8d7628dc..89f17ccf4b6 100644 --- a/packages/services/service-automation/src/builtin/config-expression-ledger.test.ts +++ b/packages/services/service-automation/src/builtin/config-expression-ledger.test.ts @@ -245,6 +245,21 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => { expect(ROLE_BY_MARKER.value).toBe('value'); }); + it.each(['create_record', 'update_record'])( + '%s.fields.* is covered — the #19938 value slot, declared where the descriptor cannot carry it', + (nodeType) => { + const slot = FLOW_NODE_EXPRESSION_PATHS.find((e) => e.nodeType === nodeType && e.path === 'fields.*'); + expect(slot, 'the ruled slot: a field value may be a CEL envelope (#11182 ruling D)').toBeDefined(); + expect(slot!.role).toBe('value'); + // Same channel as `assignments.*`: the descriptor's `fields` is + // `additionalProperties: true` (the Studio keyValue map), so the marker + // rides the spec Zod's map value and arrives through the JSON map — + // never through the descriptor, which would double-declare it. + expect(declaredFromSchemalessConfigs().map(key)).toContain(key(slot!)); + expect(declaredFromDescriptors().map(key)).not.toContain(key(slot!)); + }, + ); + it('screen.fields[].visibleWhen is covered — the #3528 regression', () => { const screen = FLOW_NODE_EXPRESSION_PATHS.find( (e) => e.nodeType === 'screen' && e.path === 'fields[].visibleWhen', diff --git a/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts b/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts new file mode 100644 index 00000000000..bfb24fe052b --- /dev/null +++ b/packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **The CEL value envelope in `create_record` / `update_record` `fields.*`** — + * the executor half of #11182 ruling D, landing with the ledger declaration + * #19938 makes (maintainer ruling B: one change, so the slot is never declared + * without being evaluated). + * + * Before this, both executors handed the whole `fields` map to `interpolate()`, + * which recursed into an envelope as plain data: a text or JSON column received + * the literal `{"dialect":"cel","source":"…"}` with the run reporting success, + * and a number column was refused by the data engine. Measured on the base + * tree before this change; the assertions below are on what reaches the STORE, + * through a real ObjectQL engine over a recording driver. + * + * Pinned: + * 1. **Evaluate** — a valid envelope in `fields.*` is evaluated by the same + * engine call the `assignment` executor makes, and its value is written, + * type kept. + * 2. **Preserve** — every non-envelope value writes exactly what the old + * whole-map `interpolate()` wrote: ruling D, no spelling changes meaning. + * 3. **Refuse at registration** — a malformed envelope stops the flow + * registering, located at `config.fields.` and led by the + * slot-neutral sentence; the evaluator refuses the same set. + * 4. **Fault loudly** — an envelope that parses but cannot evaluate on the + * live values fails the run with its source, and writes nothing. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/automation'; +import { AutomationEngine } from '../engine.js'; +import { registerCrudNodes } from './crud-nodes.js'; +import { interpolate } from './template.js'; + +function makeLogger(): any { + const l: any = { info() {}, warn() {}, error() {}, debug() {}, trace() {}, fatal() {} }; + l.child = () => l; + return l; +} + +/** Records every row that reaches the store — the row is the verdict. */ +function makeRecordingDriver() { + const writes: Array<{ op: 'create' | 'update'; data: Record }> = []; + const driver: any = { + name: 'recording', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find() { return []; }, + async findOne() { return { id: 'q1' }; }, + async create(_o: string, data: Record) { writes.push({ op: 'create', data: { ...data } }); return { id: 'q1', ...data }; }, + async update(_o: string, id: string, data: Record) { writes.push({ op: 'update', data: { ...data } }); return { id, ...data }; }, + async updateMany() { return 0; }, + async delete() { return true; }, async deleteMany() { return 0; }, async count() { return 0; }, + async bulkCreate(o: string, rows: Record[]) { return Promise.all(rows.map((r) => driver.create(o, r))); }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, writes }; +} + +async function makeStack() { + const logger = makeLogger(); + const ql = new ObjectQL({ logger }); + const { driver, writes } = makeRecordingDriver(); + ql.registerDriver(driver, true); + await ql.init(); + ql.registry.registerObject({ + name: 'quote', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + total: { name: 'total', type: 'number' }, + subject: { name: 'subject', type: 'text' }, + payload: { name: 'payload', type: 'json' }, + }, + } as any, 'test'); + const automation = new AutomationEngine(logger); + registerCrudNodes(automation, { logger, getService: (n: string) => (n === 'data' ? ql : undefined) } as any); + return { automation, writes }; +} + +type WriteNode = 'create_record' | 'update_record'; + +function writeFlow(nodeType: WriteNode, fields: Record) { + const config: Record = { objectName: 'quote', fields }; + if (nodeType === 'update_record') config.filter = { id: 'q1' }; + return { + name: 'price_quote', label: 'Price Quote', type: 'autolaunched', + variables: [ + { name: 'price', type: 'number', isInput: true }, + { name: 'rows', type: 'text', isInput: true }, + { name: 'name', type: 'text', isInput: true }, + ], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'w', type: nodeType, label: 'Write', config }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'w' }, + { id: 'e2', source: 'w', target: 'end' }, + ], + } as any; +} + +const PARAMS = { price: 1234.5678, rows: [{ subject: 'Renewal' }, { subject: '' }, { subject: 'Invoice' }], name: 'ada' }; +const run = (automation: AutomationEngine) => automation.execute('price_quote', { userId: 'u1', params: PARAMS } as any); + +const NODE_TYPES: readonly WriteNode[] = ['create_record', 'update_record']; + +/** Every way an envelope can be malformed — the assignment slot's own list. */ +const MALFORMED: ReadonlyArray<{ label: string; envelope: Record }> = [ + { label: 'no `source`', envelope: { dialect: 'cel' } }, + { label: 'an empty `source`', envelope: { dialect: 'cel', source: '' } }, + { label: 'a whitespace-only `source`', envelope: { dialect: 'cel', source: ' ' } }, + { label: 'an `ast`-only envelope', envelope: { dialect: 'cel', ast: { op: 'value' } } }, + { label: 'a non-`cel` dialect', envelope: { dialect: 'template', source: 'Hi {name}' } }, + { label: 'CEL that does not parse', envelope: { dialect: 'cel', source: 'price *' } }, + { label: 'an unknown function', envelope: { dialect: 'cel', source: 'nosuchfn(price)' } }, +]; + +describe.each(NODE_TYPES)('%s `fields.*` — a CEL value envelope is EVALUATED (#11182 ruling D)', (nodeType) => { + it('writes the evaluated value into a number column — the money case, `/ 100.0` keeping the cents', async () => { + const { automation, writes } = await makeStack(); + automation.registerFlow('price_quote', writeFlow(nodeType, { + total: { dialect: 'cel', source: 'round(price * 100.0) / 100.0' }, + })); + const res = await run(automation); + expect(res.success, res.error).toBe(true); + expect(writes).toHaveLength(1); + expect(writes[0]!.data.total).toBe(1234.57); + }); + + it('writes the evaluated value into a text column — the CEL stdlib is reachable from a field value', async () => { + const { automation, writes } = await makeStack(); + automation.registerFlow('price_quote', writeFlow(nodeType, { + subject: { dialect: 'cel', source: 'joinNonEmpty(rows.map(r, r.subject), ", ")' }, + })); + const res = await run(automation); + expect(res.success, res.error).toBe(true); + expect(writes[0]!.data.subject).toBe('Renewal, Invoice'); + }); + + it('this is a CHANGE: the same field used to be written as the envelope object', async () => { + // The pre-change behaviour, reproduced through the old code path itself: + // `interpolate()` over the whole map recursed into the envelope as data. + const envelope = { dialect: 'cel', source: 'upper(name)' }; + expect(interpolate({ payload: envelope }, new Map(Object.entries(PARAMS)), {} as any)).toEqual({ payload: envelope }); + + const { automation, writes } = await makeStack(); + automation.registerFlow('price_quote', writeFlow(nodeType, { payload: envelope })); + const res = await run(automation); + expect(res.success, res.error).toBe(true); + expect(writes[0]!.data.payload).toBe('ADA'); + }); +}); + +describe.each(NODE_TYPES)('%s `fields.*` — every non-envelope value writes exactly what it wrote before', (nodeType) => { + it('templates, literals, arrays and nested envelope-shaped JSON: byte-identical to the whole-map `interpolate()`', async () => { + const fields = { + total: '{price}', // sole token keeps its type + subject: 'Quote for {name} at {price}', // text with holes + payload: { + note: 'for {name}', // strings inside a literal still interpolate + inner: { dialect: 'cel', source: 'price * 2' }, // NESTED envelope shape — data, not evaluated + list: [{ dialect: 'cel', source: 'x' }, '{price}'], + weird: { dialect: 1 }, + }, + }; + const before = interpolate(fields, new Map(Object.entries(PARAMS)), {} as any); + + const { automation, writes } = await makeStack(); + automation.registerFlow('price_quote', writeFlow(nodeType, fields)); + const res = await run(automation); + expect(res.success, res.error).toBe(true); + const written = { ...writes[0]!.data }; + for (const k of Object.keys(written)) if (!(k in fields)) delete written[k]; // platform stamps + expect(written).toEqual(before); + expect((written.payload as { inner: unknown }).inner, 'a nested envelope is DATA').toEqual({ dialect: 'cel', source: 'price * 2' }); + }); +}); + +describe.each(NODE_TYPES)('%s `fields.*` — a malformed envelope is refused at registration, and by the evaluator', (nodeType) => { + it.each(MALFORMED)('$label: registerFlow refuses it, located at the field and led by the slot-neutral sentence', ({ envelope }) => { + const { automation } = { automation: new AutomationEngine(makeLogger()) }; + registerCrudNodes(automation, { logger: makeLogger(), getService: () => undefined } as any); + let thrown: Error | undefined; + try { + automation.registerFlow('price_quote', writeFlow(nodeType, { subject: '{name}', total: envelope })); + } catch (err) { + thrown = err as Error; + } + expect(thrown, 'a malformed envelope must not register').toBeDefined(); + // `registerFlow` answers every expression refusal with one aggregated + // message (no ADR-0112 code/status on this door) — so the pin is the + // message's substance: which node, which slot, which rule. + expect(thrown!.message).toContain(`node 'w' (${nodeType}) ${nodeType} field value at config.fields.total`); + expect(thrown!.message).toContain(VALUE_ENVELOPE_REFUSAL); + // Q2 (the seat's reading): a refused FIELD value is not told it is an assignment. + expect(thrown!.message).not.toMatch(/assignment/i); + // One notion of malformed: the evaluator refuses the same envelope. + expect(() => automation.evaluateValueEnvelope(envelope, new Map(), 'fields.total')).toThrow(VALUE_ENVELOPE_REFUSAL); + }); + + it('a valid envelope beside every non-envelope shape registers', () => { + const automation = new AutomationEngine(makeLogger()); + registerCrudNodes(automation, { logger: makeLogger(), getService: () => undefined } as any); + expect(() => automation.registerFlow('price_quote', writeFlow(nodeType, { + total: { dialect: 'cel', source: 'price * 2' }, + subject: '{name}', n: 3, ok: true, nothing: null, payload: { dialect: 1 }, list: [{ dialect: 'cel' }], + }))).not.toThrow(); + }); +}); + +describe.each(NODE_TYPES)('%s `fields.*` — a value that cannot be computed fails the run loudly and writes nothing', (nodeType) => { + it('an envelope that parses but faults on the live values', async () => { + const { automation, writes } = await makeStack(); + automation.registerFlow('price_quote', writeFlow(nodeType, { + subject: { dialect: 'cel', source: 'missing_var.subject' }, + })); + const res = await run(automation); + expect(res.success).toBe(false); + expect(res.error).toContain('fields.subject'); + expect(res.error).toContain('missing_var.subject'); + expect(writes, 'ADR-0032 §1c: no silent default is written in its place').toEqual([]); + }); +}); diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index 25bc1cdb684..a292a5ac918 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -7,6 +7,7 @@ import { CreateRecordConfigSchema, UpdateRecordConfigSchema, DeleteRecordConfigSchema, + isExpressionEnvelopeShaped, } from '@objectstack/spec/automation'; import type { GetRecordConfigParsed, @@ -14,7 +15,7 @@ import type { UpdateRecordConfigParsed, DeleteRecordConfigParsed, } from '@objectstack/spec/automation'; -import type { IDataEngine } from '@objectstack/spec/contracts'; +import type { AutomationContext, IDataEngine } from '@objectstack/spec/contracts'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; import { StandardErrorCode } from '@objectstack/spec/api'; import type { AutomationEngine } from '../engine.js'; @@ -149,6 +150,50 @@ function writtenRowCount(result: unknown): number { return 1; } +/** + * Resolve a `create_record` / `update_record` `fields` map to the values the + * write carries (#11182 ruling D — the executor half of the `fields.*` value + * slot #19938 declares in the expression ledger). + * + * Per field, by SHAPE — the rule the ledger resolver and the spec contract + * (`FlowValueSlotSchema`) draw with the same predicate, imported rather than + * re-spelled, so "which values does the validator judge" and "which values + * does the executor evaluate" cannot drift apart: + * + * - an envelope-shaped TOP-LEVEL value ({@link isExpressionEnvelopeShaped} — + * a plain object naming a string `dialect`) is a CEL value envelope, and + * is EVALUATED by `AutomationEngine.evaluateValueEnvelope`, the call the + * `assignment` executor already makes — one evaluator, one scope + * (`celScope`), one notion of malformed (`valueEnvelopeRefusals`, the call + * `registerFlow` makes). A malformed envelope throws rather than degrading + * to a literal; a value that faults on the live variables throws with its + * source (ADR-0032 §1c/§1d). Neither is written. + * - every other value goes through `interpolate()` exactly as the whole map + * used to: a `{token}` string keeps its 17.x meaning byte-for-byte (ruling + * D: no spelling changes meaning), and a literal — an array, a plain + * object, an envelope-shaped object NESTED inside either — is data, with + * its strings interpolated as before. + * + * Before this, the executor handed the whole map to `interpolate()`, which + * recursed into an envelope as plain data: a text or JSON column received the + * literal `{"dialect":"cel","source":"…"}` with the run reporting success, and + * a number column was refused by the data engine. + */ +function resolveFieldValues( + engine: AutomationEngine, + fields: Record | undefined, + variables: VariableMap, + context: AutomationContext, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(fields ?? {})) { + out[key] = isExpressionEnvelopeShaped(value) + ? engine.evaluateValueEnvelope(value, variables, `fields.${key}`) + : interpolate(value, variables, context); + } + return out; +} + /** * CRUD built-in nodes — `get_record` / `create_record` / `update_record` / * `delete_record`, wired to the runtime data layer (ObjectQL / IDataEngine). @@ -157,7 +202,9 @@ function writtenRowCount(result: unknown): number { * * Each executor: * 1. Interpolates `{var}` / `{var.path}` / `{$User.*}` / `{NOW()}` tokens in - * `node.config` against the running flow's variable context. + * `node.config` against the running flow's variable context — and, in the + * `create_record` / `update_record` `fields` map, evaluates a CEL value + * envelope to the value written ({@link resolveFieldValues}). * 2. Calls the resolved data engine via `ctx.getService('data')`. * 3. Writes the result back to the variable context under `outputVariable` * (or under `.id` / `.records` by default), so downstream @@ -291,7 +338,9 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): const objectName = cfg.objectName; if (!objectName) return refuseNode('create_record: objectName required'); - const fields = interpolate(cfg.fields ?? {}, variables, context) as Record; + // #19938 / #11182 ruling D — a CEL value envelope in `fields.*` is + // evaluated; every other value interpolates exactly as before. + const fields = resolveFieldValues(engine, cfg.fields, variables, context); const outputVariable = cfg.outputVariable; const data = getData(); @@ -447,7 +496,9 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): const filter = filterResult.filter; // `fields` is the single canonical write-map key — no alias (the wrong key // `fieldValues` is corrected at the authoring source + rejected by graph-lint). - const fields = interpolate(cfg.fields ?? {}, variables, context) as Record; + // #19938 / #11182 ruling D — a CEL value envelope in `fields.*` is + // evaluated; every other value interpolates exactly as before. + const fields = resolveFieldValues(engine, cfg.fields, variables, context); const data = getData(); if (!data) { diff --git a/packages/services/service-automation/src/builtin/logic-nodes.ts b/packages/services/service-automation/src/builtin/logic-nodes.ts index e45a470c96e..1fb75432337 100644 --- a/packages/services/service-automation/src/builtin/logic-nodes.ts +++ b/packages/services/service-automation/src/builtin/logic-nodes.ts @@ -103,7 +103,10 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext) // The exception is scoped to the shape the ledger declares, which is the // whole of what makes it safe: // • `assignments: { : }` — the canonical map, the one - // slot the ledger names, the one place an envelope is an expression; + // slot the ledger names on THIS node type, the one place in it an + // envelope is an expression (the ledger's other `value` slot is the + // CRUD `fields` map, #19938 — `crud-nodes.ts` evaluates it through + // the same `engine.evaluateValueEnvelope` call); // • `assignments: [{ variable, value }]` (legacy array) and the bare // `{ : }` config (no wrapper) — NOT declared, so an // envelope-shaped object there is the literal object it always was. diff --git a/packages/services/service-automation/src/builtin/template-functions.test.ts b/packages/services/service-automation/src/builtin/template-functions.test.ts index 5d4a25394c7..11251be21f5 100644 --- a/packages/services/service-automation/src/builtin/template-functions.test.ts +++ b/packages/services/service-automation/src/builtin/template-functions.test.ts @@ -107,8 +107,15 @@ describe('value-expression functions mirror the CEL stdlib 1:1 (#11060)', () => expect(err.problem).toBe('arity'); expect(err.fn).toBe('round'); // The prescription IS the contract here: the refusal must hand the - // author the CEL-identical authoring pattern for N-decimal rounding. - expect(err.message).toContain('round(x * 100) / 100'); + // author the N-decimal pattern that is right in BOTH dialects — + // `/ 100.0`, because in CEL `round()` is an int and `int / int` is + // integer division, so the `/ 100` spelling drops the decimals the + // moment it is copied into a CEL slot (#11182 ruling D point 2). + expect(err.message).toContain('round(x * 100) / 100.0'); + const [c100, c1000] = [cel('round(1234.5678 * 100.0) / 100'), cel('round(1234.5678 * 100.0) / 100.0')]; + expect(c100.ok && c1000.ok).toBe(true); + expect([c100.ok && c100.value, c1000.ok && c1000.value], 'the reason the decimal point is prescribed').toEqual([1234, 1234.57]); + expect(tpl('round(x * 100) / 100.0', { x: 1234.5678 }), 'and the template dialect agrees with it').toBe(1234.57); // min/max are exactly binary, like their CEL registrations. expect(() => tpl('min(1)', {})).toThrow(FlowExpressionFunctionError); expect(() => tpl('min(1, 2, 3)', {})).toThrow(FlowExpressionFunctionError); diff --git a/packages/services/service-automation/src/builtin/template.ts b/packages/services/service-automation/src/builtin/template.ts index 4727f4c76df..1a8c6a1058b 100644 --- a/packages/services/service-automation/src/builtin/template.ts +++ b/packages/services/service-automation/src/builtin/template.ts @@ -80,9 +80,17 @@ export class FlowExpressionFunctionError extends Error { * the CEL engine's public boundary (`cel-engine.ts` `coerce`) hands callers a * plain number whenever the value fits the safe-integer range. THIS dialect's * operators are plain JS, where a BigInt result would throw on the next `/` - * (`round(x * 100) / 100` — the canonical scale-2 authoring pattern, identical - * in CEL). So the table returns exactly the post-coercion value the public CEL - * surface yields. The two edges where that value CANNOT be mirrored into JS + * (`round(x * 100) / 100.0` — the scale-2 authoring pattern). So the table + * returns exactly the post-coercion value the public CEL surface yields. + * + * ⚠️ The divisor's decimal point is load-bearing, and NOT in this dialect: here + * `/` is JS division, so `/ 100` and `/ 100.0` give the same value. In CEL, + * `round()` returns an `int` and `int / int` is INTEGER division — measured, + * `round(x * 100) / 100` answers `1234` for `x = 1234.5678` where `/ 100.0` + * answers `1234.57` — so the `/ 100` spelling silently drops the decimals the + * moment it is copied into a CEL slot (a value envelope, a formula field). + * `/ 100.0` is right in both dialects, which is why it is the one prescribed + * (#11182 ruling D point 2). The two edges where that value CANNOT be mirrored into JS * arithmetic are named errors instead of silent corruption: a non-finite * argument (CEL faults there too — `BigInt(NaN)` throws inside the stdlib) and * a result beyond `Number.MAX_SAFE_INTEGER` (CEL's boundary switches carrier @@ -108,7 +116,7 @@ function requireArity(fn: string, args: unknown[]): void { // `round(x, 2)` precision form is THE anticipated misuse (#11060), so // its refusal carries the supported spelling. const precisionHint = fn === 'round' && args.length === 2 - ? ' There is no precision form — the CEL stdlib\'s round() is integer-only; for N-decimal rounding write round(x * 100) / 100 (scale 2), matching the CEL authoring pattern.' + ? ' There is no precision form — the CEL stdlib\'s round() is integer-only; for N-decimal rounding 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 / 100 drops the decimals there, while / 100.0 is right in both dialects.' : ''; throw new FlowExpressionFunctionError( fn, diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index c9d47ef9737..95c1722169d 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -33,13 +33,16 @@ import { import { predicateSlotRefusal, resolveFlowNodeExpressions, structuralConditionRefusal } from '@objectstack/spec/automation'; // [#15137] The `value`-role half of the ledger. Both halves of "is this envelope // well-formed?" are IMPORTED, never re-spelled here: the shape rule is -// `AssignmentValueSchema` (spec, #14149 — it refuses a non-`cel` dialect and the -// `{ dialect: 'cel' }` with no `source` that `validateExpression` reads as "not -// authored"), and the CEL rule is `validateExpression('value', …)` (formula). -// `registerFlow` and the run-time evaluator call the SAME composition +// `FlowValueSlotSchema` (spec, #14149; slot-neutral since #19938 — it refuses a +// non-`cel` dialect and the `{ dialect: 'cel' }` with no `source` that +// `validateExpression` reads as "not authored"), and the CEL rule is +// `validateExpression('value', …)` (formula). `registerFlow` and the run-time +// evaluator call the SAME composition // ({@link AutomationEngine.valueEnvelopeRefusals}), so a flow that registers can -// never be refused at run time and vice versa. -import { AssignmentValueSchema, ASSIGNMENT_VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/automation'; +// never be refused at run time and vice versa — for every value slot the ledger +// declares (`assignment.assignments.*`, `create_record` / `update_record` +// `fields.*`). +import { FlowValueSlotSchema, VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/automation'; // [#17322] The EVALUATED-slot rule, IMPORTED rather than re-derived. It is the // rule `FlowEdgeSchema.condition` already composes since #15807, so a node's // `config.condition` — which no schema stands in front of — is held to the same @@ -9493,15 +9496,18 @@ export class AutomationEngine implements IAutomationService { for (const found of resolveFlowNodeExpressions(node.type, node.config)) { const slotWhere = `${at}node '${node.id}' (${node.type}) ${found.entry.label} at config.${found.path}`; - // [#15137] `value` slots — the ruled `assignment.assignments.*`. - // The resolver emits ONLY envelope-shaped objects for this role - // (a plain string there is `{token}` interpolation, every other - // literal is data), so everything that arrives here is an - // author saying "this is an expression" and must be a valid - // one. Same severity as a predicate — a malformed envelope - // stops the flow registering — because the alternative is what - // this card replaced: the envelope stored verbatim and rendered - // as JSON by `notify`, with nothing said at any layer. + // [#15137] `value` slots — the ruled `assignment.assignments.*` + // and, since #19938, `create_record` / `update_record` + // `fields.*`. The resolver emits ONLY envelope-shaped objects + // for this role (a plain string there is `{token}` + // interpolation, every other literal is data), so everything + // that arrives here is an author saying "this is an + // expression" and must be a valid one. Same severity as a + // predicate — a malformed envelope stops the flow registering + // — because the alternative is what these cards replaced: the + // envelope stored verbatim (a variable `notify` rendered as + // JSON, a record column holding the envelope object), with + // nothing said at any layer. if (found.entry.role === 'value') { for (const refusal of this.valueEnvelopeRefusals(found.value)) { failures.push(` • ${slotWhere}: ${refusal.message}\n source: \`${refusal.source}\``); @@ -10562,7 +10568,8 @@ export class AutomationEngine implements IAutomationService { * {@link evaluateValueEnvelope} went on to read `envelope.source` off * nothing — a bare `TypeError` with no `where`, no source and no rule, * the one shape in that method's sweep that failed unattributed. - * 1. **Shape** — `AssignmentValueSchema` (spec, #14149). It is a no-op on + * 1. **Shape** — `FlowValueSlotSchema` (spec, #14149; the slot-neutral + * name since #19938 — `AssignmentValueSchema` is the same rule). It is a no-op on * anything not `isExpressionEnvelopeShaped`, and on an envelope it * requires `ExpressionSchema` narrowed to `dialect: 'cel'`: a `template` * or `cron` envelope is refused, and so is `{ dialect: 'cel' }` with no @@ -10571,9 +10578,11 @@ export class AutomationEngine implements IAutomationService { * 2. **CEL** — `validateExpression('value', …)` (formula), the same parse * the `predicate` role gets, minus the boolean expectation. * - * Both messages lead with the published `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` - * sentence, so an author meets the rule before the detail however the - * envelope is wrong. Neither string is re-spelled here. + * Both messages lead with the published `VALUE_ENVELOPE_REFUSAL` sentence + * (slot-neutral since #19938 — `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` is the + * same string), so an author meets the rule before the detail however the + * envelope is wrong and whichever value slot it sits in. Neither string is + * re-spelled here. * * `registerFlow` turns the result into a throw and `objectstack validate` * into a located finding (`@objectstack/lint` composes the same two calls) — @@ -10611,7 +10620,7 @@ export class AutomationEngine implements IAutomationService { if (value == null) { return [{ message: - `${ASSIGNMENT_VALUE_ENVELOPE_REFUSAL} no envelope was passed: the argument is ` + `${VALUE_ENVELOPE_REFUSAL} no envelope was passed: the argument is ` + `\`${value === null ? 'null' : 'undefined'}\`, so there is nothing to evaluate. An absent ` + 'envelope is not "not authored" — the predicate side admits absence because the condition ' + "field is optional, but a value slot's envelope IS the value. Write " @@ -10621,7 +10630,7 @@ export class AutomationEngine implements IAutomationService { } const source = (value as { source?: unknown })?.source; const sourceText = typeof source === 'string' ? source : ''; - const shape = AssignmentValueSchema.safeParse(value); + const shape = FlowValueSlotSchema.safeParse(value); if (!shape.success) { // Already prefixed with the refusal sentence by the spec's own // `superRefine` — re-prefixing would say it twice. @@ -10629,7 +10638,7 @@ export class AutomationEngine implements IAutomationService { } const parsed = validateExpression('value', value as { dialect?: string; source?: string }); return parsed.errors.map((e) => ({ - message: `${ASSIGNMENT_VALUE_ENVELOPE_REFUSAL} ${e.message}`, + message: `${VALUE_ENVELOPE_REFUSAL} ${e.message}`, source: e.source, })); } @@ -10657,8 +10666,11 @@ export class AutomationEngine implements IAutomationService { * message carries the source, per §1d. * * Only slots the ledger declares reach this: `assignment`'s canonical - * `assignments` map. The two legacy shapes the executor still normalizes - * (the `assignments: [{ variable, value }]` array and the bare + * `assignments` map, and — since #19938 (#11182 ruling D) — the + * `create_record` / `update_record` `fields` map (`crud-nodes.ts`'s + * `resolveFieldValues`, top-level field values only). The two legacy + * `assignment` shapes the executor still normalizes (the + * `assignments: [{ variable, value }]` array and the bare * `{ : }` config) are deliberately NOT declared, so an * envelope-shaped object there stays the literal object it always was. */ diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 50c3da63117..442963f4e48 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -156,6 +156,9 @@ "FlowRunSummarySchema (const)", "FlowSchema (const)", "FlowTriggerKind (type)", + "FlowValueSlot (type)", + "FlowValueSlotParsed (type)", + "FlowValueSlotSchema (const)", "FlowVariableSchema (const)", "FlowVersionHistory (type)", "FlowVersionHistoryParsed (type)", @@ -242,6 +245,7 @@ "UpdateRecordConfig (type)", "UpdateRecordConfigParsed (type)", "UpdateRecordConfigSchema (const)", + "VALUE_ENVELOPE_REFUSAL (const)", "WAIT_EXECUTOR_DESCRIPTOR (const)", "WaitEventType (type)", "WaitEventTypeSchema (const)", @@ -278,6 +282,7 @@ "parseFlowNodeRegions (function)", "predicateSlotRefusal (function)", "resolveFlowNodeExpressions (function)", + "resolveFlowNodeValueSlots (function)", "resolveFlowTriggerKind (function)", "resolveScheduleOrganization (function)", "structuralConditionRefusal (function)", diff --git a/packages/spec/declaration-map/automation.json b/packages/spec/declaration-map/automation.json index f2cd56dbebf..2e35ecc966a 100644 --- a/packages/spec/declaration-map/automation.json +++ b/packages/spec/declaration-map/automation.json @@ -86,6 +86,8 @@ "FlowRunSummary": "automation/FlowRunSummary", "FlowRunSummarySchema": "automation/FlowRunSummary", "FlowSchema": "automation/Flow", + "FlowValueSlot": "automation/FlowValueSlot", + "FlowValueSlotSchema": "automation/FlowValueSlot", "FlowVariableSchema": "automation/FlowVariable", "FlowVersionHistory": "automation/FlowVersionHistory", "FlowVersionHistorySchema": "automation/FlowVersionHistory", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 06c751b896a..43a2841f631 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 208, - "droppedRefinementSites": 601, + "publishedSchemasWithDroppedRefinements": 211, + "droppedRefinementSites": 604, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -408,6 +408,11 @@ "" ] }, + "automation/CreateRecordConfig": { + "sites": [ + "fields.valueType" + ] + }, "automation/EndConfig": { "sites": [ "" @@ -430,6 +435,11 @@ "nodes.element.lazy.in.waitEventConfig" ] }, + "automation/FlowValueSlot": { + "sites": [ + "" + ] + }, "automation/FlowVersionHistory": { "sites": [ "definition", @@ -477,6 +487,11 @@ "try.nodes.element.lazy.in.waitEventConfig" ] }, + "automation/UpdateRecordConfig": { + "sites": [ + "fields.valueType" + ] + }, "data/AggregationNode": { "sites": [ "filter.lazy" diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 0cbf69dd6c9..0f7d1d19263 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -151,6 +151,9 @@ "FlowRunSummarySchema": "src/automation/execution.zod.ts#FlowRunSummarySchema (const)", "FlowSchema": "src/automation/flow.zod.ts#FlowSchema (const)", "FlowTriggerKind": "src/automation/flow-trigger-kind.ts#FlowTriggerKind (type)", + "FlowValueSlot": "src/automation/builtin-node-config.zod.ts#FlowValueSlot (type)", + "FlowValueSlotParsed": "src/automation/builtin-node-config.zod.ts#FlowValueSlotParsed (type)", + "FlowValueSlotSchema": "src/automation/builtin-node-config.zod.ts#FlowValueSlotSchema (const)", "FlowVariableSchema": "src/automation/flow.zod.ts#FlowVariableSchema (const)", "FlowVersionHistory": "src/automation/flow.zod.ts#FlowVersionHistory (type)", "FlowVersionHistoryParsed": "src/automation/flow.zod.ts#FlowVersionHistoryParsed (type)", @@ -237,6 +240,7 @@ "UpdateRecordConfig": "src/automation/builtin-node-config.zod.ts#UpdateRecordConfig (type)", "UpdateRecordConfigParsed": "src/automation/builtin-node-config.zod.ts#UpdateRecordConfigParsed (type)", "UpdateRecordConfigSchema": "src/automation/builtin-node-config.zod.ts#UpdateRecordConfigSchema (const)", + "VALUE_ENVELOPE_REFUSAL": "src/automation/builtin-node-config.zod.ts#VALUE_ENVELOPE_REFUSAL (const)", "WAIT_EXECUTOR_DESCRIPTOR": "src/automation/node-executor.zod.ts#WAIT_EXECUTOR_DESCRIPTOR (const)", "WaitEventType": "src/automation/node-executor.zod.ts#WaitEventType (type)", "WaitEventTypeSchema": "src/automation/node-executor.zod.ts#WaitEventTypeSchema (const)", @@ -272,6 +276,7 @@ "parseFlowNodeRegions": "src/automation/control-flow.zod.ts#parseFlowNodeRegions (function)", "predicateSlotRefusal": "src/automation/flow-node-expression-paths.ts#predicateSlotRefusal (function)", "resolveFlowNodeExpressions": "src/automation/flow-node-expression-paths.ts#resolveFlowNodeExpressions (function)", + "resolveFlowNodeValueSlots": "src/automation/flow-node-expression-paths.ts#resolveFlowNodeValueSlots (function)", "resolveFlowTriggerKind": "src/automation/flow-trigger-kind.ts#resolveFlowTriggerKind (function)", "resolveScheduleOrganization": "src/automation/schedule-organization.zod.ts#resolveScheduleOrganization (function)", "structuralConditionRefusal": "src/automation/flow-node-expression-paths.ts#structuralConditionRefusal (function)", diff --git a/packages/spec/json-schema.manifest/automation.json b/packages/spec/json-schema.manifest/automation.json index 3058baed1ea..76c8a15aa24 100644 --- a/packages/spec/json-schema.manifest/automation.json +++ b/packages/spec/json-schema.manifest/automation.json @@ -46,6 +46,7 @@ "automation/FlowRunGateSummary", "automation/FlowRunNodeSummary", "automation/FlowRunSummary", + "automation/FlowValueSlot", "automation/FlowVariable", "automation/FlowVersionHistory", "automation/GetRecordConfig", diff --git a/packages/spec/src/automation/builtin-node-config.test.ts b/packages/spec/src/automation/builtin-node-config.test.ts index 2feee7b8e10..52df800e0f5 100644 --- a/packages/spec/src/automation/builtin-node-config.test.ts +++ b/packages/spec/src/automation/builtin-node-config.test.ts @@ -24,14 +24,17 @@ import { ASSIGNMENT_VALUE_ENVELOPE_REFUSAL, AssignmentConfigSchema, AssignmentExpressionValueSchema, + AssignmentValueSchema, CreateRecordConfigSchema, DeleteRecordConfigSchema, + FlowValueSlotSchema, GetRecordConfigSchema, MapConfigSchema, SCREEN_FIELD_LOOKUP_REFERENCE_REQUIRED, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema, + VALUE_ENVELOPE_REFUSAL, } from './builtin-node-config.zod.js'; import { EVALUATED_EXPRESSION_SOURCE_REQUIRED, ExpressionSchema } from '../shared/expression.zod.js'; import { @@ -547,7 +550,8 @@ describe('assignment value contract — a CEL envelope beside `{token}` interpol // is carried there by `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` — NOT by // `SCHEMALESS_NODE_CONFIG_SCHEMAS`, whose meaning ("publishes no // descriptor") other readers depend on. - expect(Object.keys(LEDGER_DECLARED_NODE_CONFIG_SCHEMAS)).toEqual(['assignment']); + // #19938 — the CRUD write map joined through the same channel. + expect(Object.keys(LEDGER_DECLARED_NODE_CONFIG_SCHEMAS)).toEqual(['assignment', 'create_record', 'update_record']); expect(Object.keys(SCHEMALESS_NODE_CONFIG_SCHEMAS).sort()).toEqual(['decision', 'script', 'subflow']); const projected = getSchemalessNodeConfigJsonSchemas().assignment as { properties?: Record }> }; @@ -791,3 +795,91 @@ describe('AssignmentConfigSchema — top-level __proto__ refused at the catchall expect(projected.properties?.assignments?.additionalProperties?.xExpression).toBe('value'); }); }); + +// ─── CRUD `fields` — a value slot (#19938) ──────────────────────────── + +/** + * #19938 (the contract half of #11182 ruling D) — the `create_record` / + * `update_record` `fields` map carries the value contract `assignments` has: + * a field value may be a CEL value envelope beside a `{token}` template or a + * literal. The widening is the valid envelope; the one newly refused shape is a + * malformed envelope (an object naming a string `dialect` that is not a valid + * CEL value envelope), the edge #14149 accepted on `assignments.*`. Everything + * else parses exactly as before. + */ +describe('CRUD `fields` value contract — the CEL value envelope beside `{token}` templates (#19938)', () => { + const PRICE_ENVELOPE = { dialect: 'cel', source: 'round(price * 100) / 100.0' }; + const configs = [ + ['create_record', CreateRecordConfigSchema, (fields: unknown) => ({ objectName: 'quote', fields })], + ['update_record', UpdateRecordConfigSchema, (fields: unknown) => ({ objectName: 'quote', filter: { id: '{quoteId}' }, fields })], + ] as const; + + it.each(configs)('%s: accepts a valid CEL value envelope as a field value, stored verbatim', (_type, schema, wrap) => { + const result = schema.safeParse(wrap({ total: PRICE_ENVELOPE })); + expect(result.success).toBe(true); + // No transform: the stored shape IS the envelope the executor evaluates. + expect((result.data as { fields: Record }).fields.total).toEqual(PRICE_ENVELOPE); + }); + + it.each(configs)('%s PRESERVATION: every field value that parsed before still parses, unchanged', (_type, schema, wrap) => { + const fields = { + subject: 'Follow up on {record.name}', // text with holes + owner: '{record.owner}', // sole token + due_date: '{TODAY() + 7}', // date macro + amount: '{round(total * 100) / 100}', // template expression + cel_looking_text: 'a + b', // a STRING is never CEL here + n: 3, ok: true, nothing: null, empty: '', + tags: ['{a}', 2, { dialect: 'cel' }], // arrays are data, envelope-shaped members included + payload: { nested: { dialect: 'cel' }, source: 'not an envelope without a dialect' }, + weird: { dialect: 1 }, // a non-string `dialect` is a literal + }; + const result = schema.safeParse(wrap(fields)); + expect(result.success).toBe(true); + expect((result.data as { fields: Record }).fields).toEqual(fields); + }); + + it.each(configs.flatMap(([type, schema, wrap]) => [ + [type, schema, wrap, 'no `source`', { dialect: 'cel' }, 'fields.total.source'], + [type, schema, wrap, 'a whitespace-only `source`', { dialect: 'cel', source: ' ' }, 'fields.total.source'], + [type, schema, wrap, 'an `ast`-only envelope', { dialect: 'cel', ast: { kind: 'const', value: 1 } }, 'fields.total.source'], + [type, schema, wrap, 'a `template` dialect', { dialect: 'template', source: 'Hi {name}' }, 'fields.total.dialect'], + [type, schema, wrap, 'an unknown dialect', { dialect: 'javascript', source: '1 + 1' }, 'fields.total.dialect'], + ] as const))('%s REFUSES a malformed envelope with %s — code `custom`, at the field\'s path, led by the slot-neutral sentence', (_type, schema, wrap, _what, envelope, path) => { + const result = schema.safeParse(wrap({ subject: '{x}', total: envelope })); + expect(result.success).toBe(false); + const issues = result.error!.issues.filter((i) => i.path[0] === 'fields'); + expect(issues.map((i) => i.path.join('.'))).toEqual([path]); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.message.startsWith(VALUE_ENVELOPE_REFUSAL)).toBe(true); + // Q2: a refused FIELD value is not told it is an assignment. + expect(issues[0]!.message).not.toMatch(/assignment/i); + }); + + it('the refusal sentence is slot-neutral, and the published assignment-era name IS it', () => { + expect(ASSIGNMENT_VALUE_ENVELOPE_REFUSAL).toBe(VALUE_ENVELOPE_REFUSAL); + expect(VALUE_ENVELOPE_REFUSAL).not.toMatch(/assignment/i); + // One rule under two descriptions: the assignment map and the CRUD map + // refuse the same envelope with the same issues. + const bad = { dialect: 'template', source: 'x' }; + const a = AssignmentValueSchema.safeParse(bad); + const f = FlowValueSlotSchema.safeParse(bad); + expect(a.success).toBe(false); + expect(f.success).toBe(false); + expect(f.error!.issues.map((i) => [i.code, i.path.join('.'), i.message])) + .toEqual(a.error!.issues.map((i) => [i.code, i.path.join('.'), i.message])); + expect(AssignmentExpressionValueSchema.safeParse(bad).error!.issues[0]!.message).not.toMatch(/assignment/i); + }); + + it.each(configs)('%s declares the slot to the expression ledger: `xExpression: \'value\'` rides onto `fields`\' map value', (type, schema) => { + const direct = z.toJSONSchema(schema, { + target: 'draft-2020-12', io: 'input', unrepresentable: 'any', + }) as { properties?: Record }> }; + expect(direct.properties?.fields?.additionalProperties?.xExpression).toBe('value'); + // …and through the JSON map the reconciliation ratchet walks. + const projected = getSchemalessNodeConfigJsonSchemas()[type] as + { properties?: Record }> }; + expect(projected.properties?.fields?.additionalProperties?.xExpression).toBe('value'); + // `update_record.filter` is a match map, not a value slot — no marker. + expect(projected.properties?.filter?.additionalProperties?.xExpression).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index bb3ea13b5fb..74528f36a79 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -67,6 +67,11 @@ * 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 @@ -223,6 +228,162 @@ const CRUD_BULK_INTENT_GUIDANCE = { options: BULK_INTENT_OPTIONS_PRESCRIPTION, } as const; +// ─── value slots — one contract for every CEL value envelope ───────── + +/** + * The one sentence a refused value envelope leads with — the same words for + * every way an envelope can be malformed AND for every `value`-role slot the + * expression ledger declares (`assignment.assignments.*`, + * `create_record.fields.*`, `update_record.fields.*`), so an author (or an + * agent reading the issue) learns the rule before the detail. + * + * Slot-neutral on purpose (#19938): the refusal's LOCATION names the slot + * (`config.fields.`, `config.assignments.`). A sentence that + * named one slot would tell an author refused in another that they had + * written something they had not. + */ +export const 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 name the sentence first shipped under (#14149), kept as a published + * export. It IS {@link VALUE_ENVELOPE_REFUSAL} — the same string — so a + * consumer matching on it goes on matching every value slot's refusal. Its + * text used to open "An assignment value…"; it became slot-neutral when the + * CRUD `fields` map joined the `value` role (#19938). + */ +export const ASSIGNMENT_VALUE_ENVELOPE_REFUSAL = VALUE_ENVELOPE_REFUSAL; + +/** + * The expression form of a value in a `value`-role slot — `ExpressionSchema`'s + * `{ dialect: 'cel', source }` envelope, narrowed to the one dialect the + * expression engine evaluates to a value (#14149, maintainer ruling + * 2026-09-02: option A, the rendering half). Named for the slot it was first + * declared on; the CRUD `fields` map's values are judged by this same schema + * (#19938) — one envelope contract, never one per slot. + * + * The envelope is the spelling `shared/expression.zod.ts` already defines and + * `validateExpression` already reads — not a second one: `ExpressionSchema` + * in its EVALUATED form (`EvaluatedExpressionSchema` — this slot's value is + * run by the expression engine, so `source` is required and non-blank, the + * one rule both spellings of that seam are refused by), `safeExtend`ed (the + * form Zod reserves for a refined object, keeping its rules) with the one + * further key narrowed, at the type level too (`dialect: 'cel'`, so a + * `template` envelope is a compile error before it is a parse error). + * `validateExpression('value', …)` refuses a `template` or `cron` envelope in + * a value slot ("expected a CEL expression but got a … dialect"), so the + * contract refuses it here, at authoring, with the same verdict — and refuses + * the shapes that validator lets through: an envelope with no `source` + * (`{ dialect: 'cel' }` and the `ast`-only envelope alike) reads as "not + * authored" there (`ok: true`), and a whitespace-only `source` trims to the + * same answer while the engine parses it untrimmed and faults. This parse is + * the gate that catches every one of them before it is stored. + * + * A bare string is deliberately NOT accepted as CEL shorthand the way + * `ExpressionInputSchema` accepts it elsewhere: in a value slot a plain + * string has always meant `{token}` flow interpolation, and that meaning is + * kept. The envelope is the only CEL spelling in this slot — which is exactly + * what lets the two forms coexist without a mode switch. + */ +export const AssignmentExpressionValueSchema = EvaluatedExpressionSchema + .safeExtend({ + dialect: z.literal('cel', { + error: () => + 'A value envelope is evaluated by the expression engine to a value, which only the `cel` dialect does — ' + + '`template` and `cron` envelopes have no meaning here. For text with holes write a plain string ' + + '(`{token}` flow interpolation); for a computed value write `{ dialect: \'cel\', source: \'…\' }`.', + }), + }) + .meta({ + description: + 'CEL value envelope `{ dialect: \'cel\', source }` — evaluated by the expression engine to the value the ' + + 'slot takes; the whole CEL stdlib (`joinNonEmpty`, …) is reachable', + }); + +export type AssignmentExpressionValue = z.input; +export type AssignmentExpressionValueParsed = z.infer; + +/** + * Build a `value`-role slot's value contract: the SHAPE rule below, the + * `.meta({ xExpression: 'value' })` marker the expression ledger's + * reconciliation ratchet reads, and the slot's own description. Every value + * slot is built here, so the rule is stated once however many slots declare + * it (#19938); only the description differs. + * + * The rule: an object that names a `dialect` ({@link isExpressionEnvelopeShaped}) + * is an envelope and must be a valid one ({@link AssignmentExpressionValueSchema}); + * every other value passes untouched. Each issue leads with + * {@link VALUE_ENVELOPE_REFUSAL}. + * + * Built eagerly, not through `lazySchema`: `.meta()` registers by schema + * IDENTITY, and the lazy Proxy is not the identity the registry holds, so a + * lazily wrapped marker never reaches the JSON Schema (measured — the sibling + * markers all sit on eager inner schemas). The schema is two nodes; nothing + * is saved by deferring it. And it is declared ABOVE the CRUD contracts that + * read it: `OS_EAGER_SCHEMAS=1` runs every `lazySchema` factory at module + * load, where a schema declared further down is still in its temporal dead + * zone. + */ +function celValueSlotSchema(description: string) { + return z.unknown() + .superRefine((value, ctx) => { + if (!isExpressionEnvelopeShaped(value)) return; + const result = AssignmentExpressionValueSchema.safeParse(value); + if (result.success) return; + for (const issue of result.error.issues) { + const where = issue.path.length > 0 ? `\`${issue.path.map(String).join('.')}\`: ` : ''; + ctx.addIssue({ + code: 'custom', + path: issue.path, + message: `${VALUE_ENVELOPE_REFUSAL} ${where}${issue.message}`, + }); + } + }) + .meta({ description, xExpression: 'value' }); +} + +/** + * What a value in a `value`-role slot may be (#14149, generalised in #19938) — + * the two authoring forms, plus literals: + * + * - a **string** — `{token}` flow interpolation, resolved by `interpolate()` + * against the live variables (a sole token keeps the token's type: + * `'{rows}'` yields the array); text with no tokens is the literal text; + * - a **CEL value envelope** — {@link AssignmentExpressionValueSchema}, + * evaluated by the expression engine to a value, so the declared stdlib is + * authorable from metadata: `joinNonEmpty(rows.map(r, r.subject), "\n")` + * builds a digest body from a list, `round(price * 100) / 100.0` a money + * value (`100.0`: CEL divides two integers as integers); + * - any other JSON value — a number, boolean, `null`, array or plain object + * — used as a literal (strings inside it still interpolate). + * + * The forms are told apart by SHAPE, never by a mode key: an object that names + * a `dialect` is an envelope ({@link isExpressionEnvelopeShaped}) and must be a + * valid one, everything else is what it always was. That is the preservation + * half of the contract — every value that parsed before a slot joined the + * `value` role still parses, and the only newly refused shape is a malformed + * envelope (no `source` — `{ dialect: 'cel' }` and an `ast`-only envelope + * alike — a blank `source`, a non-`cel` dialect), which used to be stored + * verbatim as a literal object. Only the TOP-LEVEL value of a slot is judged: + * an envelope-shaped object nested inside an array or a plain object is data. + * + * The slot-neutral contract. The CRUD `fields` map's values take it + * (`CreateRecordConfigSchema` / `UpdateRecordConfigSchema`, #19938), and it is + * what a consumer judging ANY value-role slot applies (`AutomationEngine`'s + * `valueEnvelopeRefusals`, the lint's `checkDeclaredValue`). The `assignment` + * map's {@link AssignmentValueSchema} is the same rule under a + * variable-worded description. + */ +export const FlowValueSlotSchema = celValueSlotSchema( + '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', +); + +export type FlowValueSlot = z.input; +export type FlowValueSlotParsed = z.infer; + // ─── CRUD quartet ──────────────────────────────────────────────────── /** @@ -268,9 +429,14 @@ export const CreateRecordConfigSchema = lazySchema(() => strictObject({ }, { /** Object to insert into (execute-time required). */ objectName: z.string().describe('Object to insert into'), - /** Field values to write on the new record; values interpolate `{token}` templates. */ - fields: z.record(z.string(), z.unknown()).optional() - .describe('Field values to write on the new record'), + /** + * Field values to write on the new record — a `value`-role slot of the + * expression ledger (`create_record.fields.*`, #19938): each value is a + * `{token}` template, a CEL value envelope, or a literal + * ({@link FlowValueSlotSchema}). + */ + fields: z.record(z.string(), FlowValueSlotSchema).optional() + .describe('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'), /** Flow variable bound to the created record (`{var.id}` works even when the driver returns a bare id). */ outputVariable: z.string().optional() .describe('Flow variable bound to the created record'), @@ -299,8 +465,13 @@ export const UpdateRecordConfigSchema = lazySchema(() => strictObject({ /** Field/value pairs identifying the record(s) to update; an erased template condition refuses the node (#3810). */ filter: z.record(z.string(), z.unknown()).optional() .describe('Field/value pairs identifying the record(s) to update'), - /** Field values to write; values interpolate `{token}` templates. */ - fields: z.record(z.string(), z.unknown()).optional().describe('Field values to write'), + /** + * Field values to write — a `value`-role slot of the expression ledger + * (`update_record.fields.*`, #19938): each value is a `{token}` template, a + * CEL value envelope, or a literal ({@link FlowValueSlotSchema}). + */ + fields: z.record(z.string(), FlowValueSlotSchema).optional() + .describe('Field values to write: each key is a field name, each value a `{token}` template, a CEL value envelope, or a literal'), /** * Declare BULK intent — this node may update EVERY row `filter` matches. * @@ -769,83 +940,9 @@ export type MapConfigParsed = z.infer; // ─── assignment ────────────────────────────────────────────────────── /** - * The one sentence a refused envelope leads with — the same words for every - * way an envelope can be malformed, so an author (or an agent reading the - * issue) learns the rule before the detail. - */ -export const ASSIGNMENT_VALUE_ENVELOPE_REFUSAL = - 'An assignment value carrying a `dialect` key is read as an expression envelope, and this one is not a valid ' - + 'CEL value envelope.'; - -/** - * The expression form of an assignment value — `ExpressionSchema`'s - * `{ dialect: 'cel', source }` envelope, narrowed to the one dialect the - * expression engine evaluates to a value (#14149, maintainer ruling - * 2026-09-02: option A, the rendering half). - * - * The envelope is the spelling `shared/expression.zod.ts` already defines and - * `validateExpression` already reads — not a second one: `ExpressionSchema` - * in its EVALUATED form (`EvaluatedExpressionSchema` — this slot's value is - * run by the expression engine, so `source` is required and non-blank, the - * one rule both spellings of that seam are refused by), `safeExtend`ed (the - * form Zod reserves for a refined object, keeping its rules) with the one - * further key narrowed, at the type level too (`dialect: 'cel'`, so a - * `template` envelope is a compile error before it is a parse error). - * `validateExpression('value', …)` refuses a `template` or `cron` envelope in - * a value slot ("expected a CEL expression but got a … dialect"), so the - * contract refuses it here, at authoring, with the same verdict — and refuses - * the shapes that validator lets through: an envelope with no `source` - * (`{ dialect: 'cel' }` and the `ast`-only envelope alike) reads as "not - * authored" there (`ok: true`), and a whitespace-only `source` trims to the - * same answer while the engine parses it untrimmed and faults. This parse is - * the gate that catches every one of them before it is stored. - * - * A bare string is deliberately NOT accepted as CEL shorthand the way - * `ExpressionInputSchema` accepts it elsewhere: in an assignment value a plain - * string has always meant `{token}` flow interpolation, and that meaning is - * kept. The envelope is the only CEL spelling in this slot — which is exactly - * what lets the two forms coexist without a mode switch. - */ -export const AssignmentExpressionValueSchema = EvaluatedExpressionSchema - .safeExtend({ - dialect: z.literal('cel', { - error: () => - 'An assignment value envelope is evaluated by the expression engine to a value, which only the `cel` dialect ' - + 'does — `template` and `cron` envelopes have no meaning here. For text with holes write a plain string ' - + '(`{token}` flow interpolation); for a computed value write `{ dialect: \'cel\', source: \'…\' }`.', - }), - }) - .meta({ - description: - 'CEL value envelope `{ dialect: \'cel\', source }` — evaluated by the expression engine to the value the ' - + 'variable takes; the whole CEL stdlib (`joinNonEmpty`, …) is reachable', - }); - -export type AssignmentExpressionValue = z.input; -export type AssignmentExpressionValueParsed = z.infer; - -/** - * What an assignment value may be (#14149) — the two authoring forms, plus - * literals: - * - * - a **string** — `{token}` flow interpolation, resolved by `interpolate()` - * against the live variables (a sole token keeps the token's type: `'{rows}'` - * assigns the array); text with no tokens is the literal text; - * - a **CEL value envelope** — {@link AssignmentExpressionValueSchema}, - * evaluated by the expression engine to a value, so the declared stdlib is - * authorable from metadata: `joinNonEmpty(rows.map(r, r.subject), "\n")` - * builds a digest body from a list; - * - any other JSON value — a number, boolean, `null`, array or plain object - * — assigned as a literal (strings inside it still interpolate). - * - * The forms are told apart by SHAPE, never by a mode key: an object that names - * a `dialect` is an envelope ({@link isExpressionEnvelopeShaped}) and must be a - * valid one, everything else is what it always was. That is the preservation - * half of the contract — every value that parsed before #14149 still parses, - * and the only newly refused shape is a malformed envelope (no `source` — - * `{ dialect: 'cel' }` and an `ast`-only envelope alike — a blank `source`, a - * non-`cel` dialect), which used to be stored verbatim as a literal object and - * then rendered by `notify` as JSON. + * What an assignment value may be (#14149) — {@link FlowValueSlotSchema}'s rule + * (built by the same factory), under the variable-worded description the + * `assignments` map publishes. * * `.meta({ xExpression: 'value' })` is the declaration channel the expression * ledger reads for this slot (`FLOW_NODE_EXPRESSION_PATHS`'s `assignment` @@ -857,34 +954,12 @@ export type AssignmentExpressionValueParsed = z.infer { - if (!isExpressionEnvelopeShaped(value)) return; - const result = AssignmentExpressionValueSchema.safeParse(value); - if (result.success) return; - for (const issue of result.error.issues) { - const where = issue.path.length > 0 ? `\`${issue.path.map(String).join('.')}\`: ` : ''; - ctx.addIssue({ - code: 'custom', - path: issue.path, - message: `${ASSIGNMENT_VALUE_ENVELOPE_REFUSAL} ${where}${issue.message}`, - }); - } - }) - .meta({ - description: - 'Value the variable takes: 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', - xExpression: 'value', - }); +export const AssignmentValueSchema = celValueSlotSchema( + 'Value the variable takes: 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', +); export type AssignmentValue = z.input; export type AssignmentValueParsed = z.infer; diff --git a/packages/spec/src/automation/flow-node-expression-paths.test.ts b/packages/spec/src/automation/flow-node-expression-paths.test.ts index 06fc02c6c82..82199f2b21e 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.test.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.test.ts @@ -20,6 +20,7 @@ import { FLOW_NODE_EXPRESSION_PATHS, isExpressionEnvelopeShaped, resolveFlowNodeExpressions, + resolveFlowNodeValueSlots, predicateSlotRefusal, PREDICATE_SLOT_STRING_REFUSAL, structuralConditionRefusal, @@ -122,6 +123,100 @@ describe('FLOW_NODE_EXPRESSION_PATHS — the assignment value entry (#14149)', ( }); }); +/** + * #19938 (the contract half of #11182 ruling D) — `create_record` / + * `update_record` `fields.*` is a `value` slot, the same shape and dialect + * rules as `assignments.*`: only an envelope-shaped TOP-LEVEL field value is + * an expression, a `{token}` string keeps its 17.x meaning and is not + * resolved, and every other literal is data. + */ +describe('FLOW_NODE_EXPRESSION_PATHS — the CRUD `fields.*` value entries (#19938)', () => { + const PRICE_ENVELOPE = { dialect: 'cel', source: 'round(price * 100) / 100.0' }; + + it.each(['create_record', 'update_record'] as const)('declares exactly one slot for `%s`: `fields.*`, role `value`', (nodeType) => { + const entries = FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.nodeType === nodeType); + expect(entries).toHaveLength(1); + expect(entries[0]!.path).toBe('fields.*'); + expect(entries[0]!.role).toBe('value'); + expect(entries[0]!.label).toBe(`${nodeType} field value`); + }); + + it.each(['create_record', 'update_record'] as const)('%s: resolves the envelope field value, and only it, at the author\'s field name', (nodeType) => { + const found = resolveFlowNodeExpressions(nodeType, { + objectName: 'quote', + filter: { id: '{quoteId}' }, + fields: { + subject: 'Quote for {account.name}', // `{token}` text — interpolation, not resolved + owner: '{$User.Id}', + discount: 10, + total: PRICE_ENVELOPE, + broken: { dialect: 'cel' }, // malformed — resolved so a validator refuses it + }, + }); + expect(found.map((f) => f.path)).toEqual(['fields.total', 'fields.broken']); + expect(found[0]!.value).toBe(PRICE_ENVELOPE); + expect(found.every((f) => f.entry.role === 'value' && f.entry.nodeType === nodeType)).toBe(true); + }); + + it('only the TOP-LEVEL field value is judged — an envelope nested in a JSON value, or in an array, is data', () => { + expect(resolveFlowNodeExpressions('create_record', { + fields: { + payload: { nested: { dialect: 'cel', source: 'x' } }, + tags: [{ dialect: 'cel', source: 'x' }], + decoy: { dialect: 7, source: 'x' }, + }, + })).toEqual([]); + }); + + it('`update_record.filter` is NOT a value slot — an envelope-shaped filter value resolves nothing', () => { + expect(resolveFlowNodeExpressions('update_record', { filter: { total: PRICE_ENVELOPE } })).toEqual([]); + }); + + it('`get_record` / `delete_record` declare no value slot — their `fields` / `filter` resolve nothing', () => { + expect(resolveFlowNodeExpressions('get_record', { fields: ['id'], filter: { total: PRICE_ENVELOPE } })).toEqual([]); + expect(resolveFlowNodeExpressions('delete_record', { filter: { total: PRICE_ENVELOPE } })).toEqual([]); + }); + + it('a `fields` that is not a plain object resolves nothing rather than throwing', () => { + expect(resolveFlowNodeExpressions('create_record', { fields: [PRICE_ENVELOPE] })).toEqual([]); + expect(resolveFlowNodeExpressions('create_record', { fields: 'nope' })).toEqual([]); + expect(resolveFlowNodeExpressions('create_record', { fields: null })).toEqual([]); + expect(resolveFlowNodeExpressions('create_record', {})).toEqual([]); + }); +}); + +describe('resolveFlowNodeValueSlots — every authored value of a `value` slot, strings included (#19938)', () => { + it('hands over every non-absent value of the CRUD `fields` map and the assignment map, by the ledger\'s own walk', () => { + const envelope = { dialect: 'cel', source: 'price * 2' }; + expect(resolveFlowNodeValueSlots('create_record', { + objectName: 'quote', + fields: { subject: 'Hi {name}', total: envelope, n: 3, nothing: null, gone: undefined }, + }).map((f) => [f.path, f.value, f.entry.path])).toEqual([ + ['fields.subject', 'Hi {name}', 'fields.*'], + ['fields.total', envelope, 'fields.*'], + ['fields.n', 3, 'fields.*'], + ['fields.nothing', null, 'fields.*'], + ]); + expect(resolveFlowNodeValueSlots('assignment', { assignments: { total: '{round(x)}' } }).map((f) => f.path)) + .toEqual(['assignments.total']); + }); + + it('reaches ONLY `value` slots — a predicate or flow-template slot, an undeclared map, a legacy shape: nothing', () => { + expect(resolveFlowNodeValueSlots('screen', { fields: [{ visibleWhen: 'a == 1' }] })).toEqual([]); + expect(resolveFlowNodeValueSlots('loop', { collection: '{rows}' })).toEqual([]); + expect(resolveFlowNodeValueSlots('update_record', { filter: { id: '{x}' } })).toEqual([]); + expect(resolveFlowNodeValueSlots('assignment', { digest: '{x}' })).toEqual([]); + expect(resolveFlowNodeValueSlots('assignment', { assignments: [{ variable: 'd', value: '{x}' }] })).toEqual([]); + expect(resolveFlowNodeValueSlots('create_record', null)).toEqual([]); + }); + + it('agrees with `resolveFlowNodeExpressions` on the envelope subset — one walk, two views', () => { + const config = { fields: { a: '{x}', b: { dialect: 'cel', source: '1' }, c: { dialect: 'cel' }, d: 4 } }; + const envelopes = resolveFlowNodeValueSlots('update_record', config).filter((f) => isExpressionEnvelopeShaped(f.value)); + expect(envelopes).toEqual(resolveFlowNodeExpressions('update_record', config)); + }); +}); + describe('isExpressionEnvelopeShaped — the recognizer a value slot discriminates on', () => { it('is a plain object with a string `dialect`, and nothing else', () => { expect(isExpressionEnvelopeShaped({ dialect: 'cel', source: '1' })).toBe(true); @@ -139,13 +234,18 @@ describe('isExpressionEnvelopeShaped — the recognizer a value slot discriminat describe('every pre-#14149 entry resolves byte-identically (the ratchet\'s fixtures, restated)', () => { const byKey = (e: FlowNodeExpressionPath) => `${e.nodeType}.${e.path} (${e.role})`; - it('the four entries that existed before are still declared exactly as they were', () => { + it('the entries that existed before are still declared exactly as they were — #19938 added exactly two rows', () => { + // The census: five rows before #19938, seven after. The two new rows are + // the CRUD write map's `value` slots and sit at the end; every row above + // them is byte-identical to what it was. expect(FLOW_NODE_EXPRESSION_PATHS.map(byKey)).toEqual([ 'screen.fields[].visibleWhen (predicate)', 'decision.conditions[].expression (predicate)', 'loop.collection (flow-template)', 'map.collection (flow-template)', 'assignment.assignments.* (value)', + 'create_record.fields.* (value)', + 'update_record.fields.* (value)', ]); }); diff --git a/packages/spec/src/automation/flow-node-expression-paths.ts b/packages/spec/src/automation/flow-node-expression-paths.ts index a0dc4af22b4..0ed1359c87e 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.ts @@ -120,12 +120,17 @@ export type FlowNodeExpressionRole = * `registerFlow` and `objectstack validate` by the executor-side half * (`service-automation` / `lint` consumers call * `validateExpression('value', …)` on what this ledger resolves, after - * `AssignmentValueSchema` has judged the envelope's shape); the same half + * `FlowValueSlotSchema` has judged the envelope's shape); the same half * evaluates the envelope at run time. That half LANDED in #15137: the * built-in `assignment` executor evaluates a declared envelope and assigns * the result. Comment-only correction — before #15137 this paragraph closed * by saying the executor still wrote the envelope object into the variable * verbatim, which the landing made false. + * + * Declared since #19938 for the `create_record` / `update_record` `fields` + * map too (#11182 ruling D: CEL usable in every value slot in 17.x) — where + * most authored value expressions live — declared and evaluated in the same + * change, so the slot was never declared without its executor half. */ | 'value'; @@ -161,7 +166,7 @@ export interface FlowNodeExpressionPath { * * Also deliberately absent: config values that merely INTERPOLATE `{token}` * templates — `script.inputs` / `script.variables` / `subflow.input`, - * `notify.body`, `create_record.fields.*` and so on. Those are text-with-holes, + * `notify.body` and so on. Those are text-with-holes, * the shape essentially every node config string has, already covered * generically (`validate-flow-template-paths`, the CLI flow linter's * `collectTemplateStrings`). A `flow-template` ledger entry means something @@ -173,15 +178,18 @@ export interface FlowNodeExpressionPath { * A `value` entry (#14149) is listed for a third reason, not either of those: * the slot's authored value may be an expression *envelope* — `{ dialect: * 'cel', source }`, a shape no `{token}` interpolation ever produced — and only - * that form is resolved. The `assignment` node's `assignments` map is the one - * such slot; its `{token}` strings stay the generic text-with-holes case above. - * It is declared through the spec Zod channel (`AssignmentConfigSchema`'s map - * value, `.meta({ xExpression: 'value' })`, exposed to the ratchet through - * `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` in `schemaless-node-config.zod.ts`) - * because the node's descriptor declares the map as `additionalProperties: - * true` with no marker; the ratchet walks an object-valued - * `additionalProperties` as the `*` segment and maps the `value` marker to - * this role. + * that form is resolved. Three maps are such slots: the `assignment` node's + * `assignments` (#14149) and the `create_record` / `update_record` `fields` + * (#19938). Their `{token}` strings stay the generic text-with-holes case + * above — a template in `fields.*` means exactly what it meant before the + * slot was declared. Each is declared through the spec Zod channel (the map + * value's `.meta({ xExpression: 'value' })` on `AssignmentConfigSchema` / + * `CreateRecordConfigSchema` / `UpdateRecordConfigSchema`, exposed to the + * ratchet through `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` in + * `schemaless-node-config.zod.ts`) because each node's descriptor declares + * the map as `additionalProperties: true` with no marker; the ratchet walks + * an object-valued `additionalProperties` as the `*` segment and maps the + * `value` marker to this role. */ export const FLOW_NODE_EXPRESSION_PATHS: readonly FlowNodeExpressionPath[] = [ { @@ -235,6 +243,31 @@ export const FLOW_NODE_EXPRESSION_PATHS: readonly FlowNodeExpressionPath[] = [ role: 'value', label: 'assignment value', }, + { + // The CRUD write map (#19938, the contract half of #11182 ruling D): every + // value of `fields` — `{ : }`, keys authored by the flow + // author — is a `value` slot, the same shape and dialect rules as + // `assignments.*`. A plain string there stays `{token}` interpolation with + // its 17.x meaning unchanged; only the envelope form is new. Declared + // through the spec Zod channel (`CreateRecordConfigSchema`'s map value, + // `FlowValueSlotSchema`): the descriptor's own `fields` is + // `additionalProperties: true` and carries no marker, exactly like the + // `assignment` map above. Only the TOP-LEVEL value of each field is + // judged — an envelope-shaped object nested inside a JSON value is data. + nodeType: 'create_record', + path: 'fields.*', + role: 'value', + label: 'create_record field value', + }, + { + // The same map on the update node (`UpdateRecordConfigSchema`); its + // `filter` map is NOT a value slot — a filter is a match condition, not a + // value to compute. + nodeType: 'update_record', + path: 'fields.*', + role: 'value', + label: 'update_record field value', + }, ]; /** One resolved expression value found in a node's config. */ @@ -338,11 +371,39 @@ export function resolveFlowNodeExpressions( return out; } +/** + * Every authored value sitting in a `value`-role slot for `nodeType` — strings + * and literals included, not only envelopes (#19938). + * + * {@link resolveFlowNodeExpressions} emits only the envelope form for this + * role, because only an envelope is an expression to CHECK. Tooling that + * reasons about the other form such a slot accepts — the lint's author-time + * hint that points a `{…}` template expression at the CEL value envelope + * (#11182 ruling D) — needs the strings too, located by the SAME path walk, so + * it can never disagree with the ledger about which positions are value slots. + * An absent (`undefined`) value is skipped; everything else is handed over + * verbatim, with its concrete path. + * + * Pure path resolution, like its sibling — no judgement about what a value + * says. + */ +export function resolveFlowNodeValueSlots(nodeType: string, config: unknown): ResolvedFlowNodeExpression[] { + if (config == null || typeof config !== 'object') return []; + const out: ResolvedFlowNodeExpression[] = []; + for (const entry of FLOW_NODE_EXPRESSION_PATHS) { + if (entry.nodeType !== nodeType || entry.role !== 'value') continue; + walk(config as Record, entry.path.split('.'), '', (path, value) => { + if (value !== undefined) out.push({ entry, path, value }); + }); + } + return out; +} + /** * The one sentence a refused `predicate` slot leads with (#15572) — the same * words however the value is wrong, so an author (or an agent reading the * failure) learns the rule before the detail. Mirrors - * `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL`, the `value` role's equivalent. + * `VALUE_ENVELOPE_REFUSAL`, the `value` role's equivalent. * * #17493 widened the sentence rather than adding a second one: a string that is * blank after trimming is refused by the same rule, so the sentence names it. @@ -378,7 +439,8 @@ export const PREDICATE_SLOT_STRING_REFUSAL = * ## Why this is a refusal and not a parse * * The `{ dialect, source }` envelope is the `value` role's spelling - * (`assignment.assignments.*`, the 2026-09-02 ruling on #14149). In a predicate + * (`assignment.assignments.*`, the 2026-09-02 ruling on #14149; the CRUD + * `fields.*` since #19938). In a predicate * slot it was a shape NOBODY could see: the slot is declared `z.string()`, but * a node's `config` is an open `z.record(z.unknown())` that no Zod schema is * parsed against, the unknown-key walk exempts the schemaless node types @@ -425,8 +487,8 @@ export function predicateSlotRefusal(value: unknown): { message: string; source: message: `${PREDICATE_SLOT_STRING_REFUSAL} Found ${found}. Write the predicate as bare CEL text ` + '(e.g. `record.rating >= 4`); the `{ dialect, source }` envelope is the `value`-role spelling ' - + '(the `assignment` node\'s `assignments` map), and in a predicate slot it is read by the evaluator ' - + 'but by neither validator.', + + '(the `assignment` node\'s `assignments` map, a `create_record` / `update_record` node\'s `fields` map), ' + + 'and in a predicate slot it is read by the evaluator but by neither validator.', source: typeof rawSource === 'string' ? rawSource : '', }; } diff --git a/packages/spec/src/automation/node-executor.zod.ts b/packages/spec/src/automation/node-executor.zod.ts index 21db4a9c7a3..5945ff64e29 100644 --- a/packages/spec/src/automation/node-executor.zod.ts +++ b/packages/spec/src/automation/node-executor.zod.ts @@ -270,9 +270,10 @@ export const ActionDescriptorSchema = lazySchema(() => z.object({ * The ledger's third role, `value` (#14149), names a slot whose authored * value may be a `{ dialect: 'cel', source }` envelope evaluated by the * expression engine to a value — today the `assignment` node's - * `assignments.*` — declared through the spec Zod contract's - * `.meta({ xExpression: 'value' })` (`AssignmentValueSchema`); only the - * envelope form resolves, a plain string there stays `{var}` interpolation. + * `assignments.*` and the `create_record` / `update_record` `fields.*` — + * declared through the spec Zod contract's `.meta({ xExpression: 'value' })` + * (`AssignmentValueSchema`, `FlowValueSlotSchema`); only the envelope form + * resolves, a plain string there stays `{var}` interpolation. * Its `validateExpression('value', …)` check and its run-time evaluation * are the executor half, in `service-automation`. * - **Types and `required` are enforced at execute time for the diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts index 67cb1f11928..f1056ebc982 100644 --- a/packages/spec/src/automation/schemaless-node-config.zod.ts +++ b/packages/spec/src/automation/schemaless-node-config.zod.ts @@ -105,7 +105,11 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; -import { AssignmentConfigSchema } from './builtin-node-config.zod'; +import { + AssignmentConfigSchema, + CreateRecordConfigSchema, + UpdateRecordConfigSchema, +} from './builtin-node-config.zod'; /** * What a rejected key on these contracts silently did before #4001 批 9 — and @@ -527,22 +531,26 @@ export type SchemalessNodeType = keyof typeof SCHEMALESS_NODE_CONFIG_SCHEMAS; /** * Node types that DO publish a descriptor `configSchema` and still declare an * expression slot through a spec Zod, because the descriptor cannot carry the - * marker (#14149). - * - * `assignment`'s descriptor declares `assignments` as `additionalProperties: - * true` — the openness IS its contract, pinned by the form↔Zod ledger — so - * there is no descriptor property to mark. The value contract lives on - * `AssignmentConfigSchema`'s map value (`.meta({ xExpression: 'value' })`, + * marker (#14149; the CRUD pair since #19938). + * + * Each of these descriptors declares its map as `additionalProperties: true` + * — `assignment`'s `assignments`, `create_record` / `update_record`'s + * `fields` — and that openness IS the descriptor's contract, pinned by the + * form↔Zod ledger, so there is no descriptor property to mark. The value + * contract lives on the spec Zod's map value (`.meta({ xExpression: 'value' })` + * — `AssignmentValueSchema` and `FlowValueSlotSchema`, * `builtin-node-config.zod.ts`), and the expression ledger's reconciliation * ratchet reads it from the JSON projection below, walking the map's - * `additionalProperties` as the `*` segment the ledger path `assignments.*` - * spells. Kept apart from {@link SCHEMALESS_NODE_CONFIG_SCHEMAS} on purpose: - * that map means "publishes no descriptor", its other readers + * `additionalProperties` as the `*` segment the ledger paths `assignments.*` + * and `fields.*` spell. Kept apart from {@link SCHEMALESS_NODE_CONFIG_SCHEMAS} + * on purpose: that map means "publishes no descriptor", its other readers * (`metadata-protocol`'s reference-site attribution) walk it for that reason, - * and `assignment` is not a member of that class. + * and none of these three is a member of that class. */ export const LEDGER_DECLARED_NODE_CONFIG_SCHEMAS = { assignment: AssignmentConfigSchema, + create_record: CreateRecordConfigSchema, + update_record: UpdateRecordConfigSchema, } as const satisfies Record; /** Node types whose expression slots reach the ledger through {@link LEDGER_DECLARED_NODE_CONFIG_SCHEMAS}. */ @@ -563,8 +571,8 @@ export type ReconciledNodeConfigType = SchemalessNodeType | LedgerDeclaredNodeTy * property, and (since #14149) on a map's `additionalProperties`. * * These are **not** published on a descriptor — that is the whole point of the - * schemaless class (see this module's header), and `assignment`'s descriptor - * publishes its map without the marker — so nothing here reaches the Studio + * schemaless class (see this module's header), and the ledger-declared types' + * descriptors publish their maps without the marker — so nothing here reaches the Studio * property form. It exists so validation ledgers and reconciliation ratchets * can see these contracts at all. */