From de7c2894245d8c460c2106f51321187892e610f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 07:14:11 +0000 Subject: [PATCH 1/4] feat(spec): declare create_record / update_record fields.* as a value-role CEL envelope slot The expression ledger gains two `value` rows, `create_record.fields.*` and `update_record.fields.*`: the same shape and dialect rules the `assignment.assignments.*` slot has. A field value may now be a `{ dialect: 'cel', source }` envelope beside a `{token}` template or a literal; a plain string keeps its template meaning unchanged. - The CRUD `fields` map's values carry the value-slot contract (`FlowValueSlotSchema`), declared to the reconciliation ratchet through `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` because the descriptors publish the map as `additionalProperties: true`. - The refusal sentence is slot-neutral (`VALUE_ENVELOPE_REFUSAL`); the published `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` is the same string, so a refused field value is no longer told it is an assignment. - One factory builds every value slot's contract, declared above the CRUD schemas so `OS_EAGER_SCHEMAS=1` never meets it in its temporal dead zone. - `resolveFlowNodeValueSlots` hands tooling every authored value of a value slot (strings included) through the ledger's own path walk. - Generated artefacts regenerated; the three new dropped-refinement sites are declared in the ledger with its header totals. The executor half lands in the next commit of the same change, so the slot is never declared without being evaluated. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../automation/builtin-node-config.mdx | 22 +- content/docs/references/index.mdx | 10 +- .../builtin/config-expression-ledger.test.ts | 15 + packages/spec/api-surface/automation.json | 5 + packages/spec/declaration-map/automation.json | 2 + .../spec/dropped-refinements.baseline.json | 19 +- packages/spec/export-origins/automation.json | 5 + .../spec/json-schema.manifest/automation.json | 1 + .../automation/builtin-node-config.test.ts | 94 +++++- .../src/automation/builtin-node-config.zod.ts | 293 +++++++++++------- .../flow-node-expression-paths.test.ts | 102 +++++- .../automation/flow-node-expression-paths.ts | 92 +++++- .../spec/src/automation/node-executor.zod.ts | 7 +- .../automation/schemaless-node-config.zod.ts | 34 +- 14 files changed, 547 insertions(+), 154 deletions(-) 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 d49933c191c..035e3882f7c 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 — 1538 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1539 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 | 444 | REST contracts, endpoints, routing, realtime, batch, discovery. | -| [Automation Protocol](/docs/references/automation) | 14 | 75 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | +| [Automation Protocol](/docs/references/automation) | 14 | 76 | 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** | **1538** | 14 protocol modules | +| **Total** | **196** | **1539** | 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, 75 schemas** +**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 76 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`, `ScheduleState` | | [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` | 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/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 7dd61f5447b..bdbef6f983c 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)", @@ -245,6 +248,7 @@ "UpdateRecordConfig (type)", "UpdateRecordConfigParsed (type)", "UpdateRecordConfigSchema (const)", + "VALUE_ENVELOPE_REFUSAL (const)", "WAIT_EXECUTOR_DESCRIPTOR (const)", "WaitEventType (type)", "WaitEventTypeSchema (const)", @@ -281,6 +285,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 97be6e386b4..5d400e7713e 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 77a87e632cc..12fbbd05a29 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": 207, - "droppedRefinementSites": 574, + "publishedSchemasWithDroppedRefinements": 210, + "droppedRefinementSites": 577, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -395,6 +395,11 @@ "" ] }, + "automation/CreateRecordConfig": { + "sites": [ + "fields.valueType" + ] + }, "automation/EndConfig": { "sites": [ "" @@ -417,6 +422,11 @@ "nodes.element.lazy.in.waitEventConfig" ] }, + "automation/FlowValueSlot": { + "sites": [ + "" + ] + }, "automation/FlowVersionHistory": { "sites": [ "definition", @@ -464,6 +474,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 8b1dd265d11..4063aa9969e 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)", @@ -240,6 +243,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)", @@ -275,6 +279,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 c42c760c510..2e804da5f41 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. */ From 8e37b80ff07c22e04698bb2a7005137825ad4655 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 07:14:21 +0000 Subject: [PATCH 2/4] feat(automation): evaluate a CEL value envelope in create_record / update_record fields.* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The executor half of the `fields.*` value slot, landing with its declaration. - `crud-nodes.ts`: each top-level `fields` value that is envelope-shaped is evaluated through `AutomationEngine.evaluateValueEnvelope` (the call the `assignment` executor makes: one evaluator, one scope, one notion of malformed); every other value interpolates exactly as the whole-map `interpolate()` did, so no template spelling changes meaning. A malformed or faulting envelope fails the node and writes nothing. - `engine.ts` / `validate-expressions.ts`: the value-slot consumers read the slot-neutral `FlowValueSlotSchema` and `VALUE_ENVELOPE_REFUSAL`. - `@objectstack/lint`: an author-time warning points a `{…}` template expression (arithmetic or one of the six functions) in any value slot at the envelope. Plain references, the date macros and `$User` paths are not hinted. - `template.ts`: the `round()` arity refusal and its docblock prescribe `round(x * 100) / 100.0`. In CEL `round()` is an int and int / int is integer division, so `/ 100` drops the decimals there. - `flows.mdx`: value slots, the envelope in a Create Record example, the refusal doors, and the dialect table's scale-2 row. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../19938-fields-value-slot-cel-envelope.md | 37 +++ content/docs/automation/flows.mdx | 51 ++-- ...date-expressions.fields-value-slot.test.ts | 164 +++++++++++++ .../lint/src/validate-expressions.test.ts | 4 + packages/lint/src/validate-expressions.ts | 103 +++++++- .../crud-fields-value-envelope.test.ts | 227 ++++++++++++++++++ .../src/builtin/crud-nodes.ts | 59 ++++- .../src/builtin/logic-nodes.ts | 5 +- .../src/builtin/template-functions.test.ts | 11 +- .../src/builtin/template.ts | 16 +- .../services/service-automation/src/engine.ts | 60 +++-- 11 files changed, 676 insertions(+), 61 deletions(-) create mode 100644 .changeset/19938-fields-value-slot-cel-envelope.md create mode 100644 packages/lint/src/validate-expressions.fields-value-slot.test.ts create mode 100644 packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts 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/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 d930c410e57..eba33039fca 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 99b55164f06..171b9cfb48c 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -83,15 +83,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 @@ -108,6 +110,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; @@ -1196,9 +1260,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 @@ -1211,7 +1275,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' }); @@ -1219,7 +1283,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' }); @@ -1430,6 +1494,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/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. */ From 96701231650faea47a5d9b7e610727c43d8916a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:57:22 +0000 Subject: [PATCH 3/4] chore(spec): regenerate the generated artefacts after merging main The merge took main's side of the five os-regen artefacts both sides had changed (content/docs/references/index.mdx, api-surface, declaration-map, export-origins and json-schema.manifest for automation); this commit regenerates them from the merged sources. Against main they differ only by this branch's own additions (FlowValueSlot / FlowValueSlotParsed / FlowValueSlotSchema, VALUE_ENVELOPE_REFUSAL, resolveFlowNodeValueSlots, and the +1 schema count). Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- content/docs/references/index.mdx | 16 ++++++++-------- packages/spec/api-surface/automation.json | 3 --- packages/spec/declaration-map/automation.json | 2 -- packages/spec/export-origins/automation.json | 3 --- .../spec/json-schema.manifest/automation.json | 1 - 5 files changed, 8 insertions(+), 17 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 035e3882f7c..fa305984be4 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 — 1539 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1526 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/. */} @@ -20,8 +20,8 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 32 | 444 | REST contracts, endpoints, routing, realtime, batch, discovery. | -| [Automation Protocol](/docs/references/automation) | 14 | 76 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | +| [API Protocol](/docs/references/api) | 32 | 432 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [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** | **1539** | 14 protocol modules | +| **Total** | **196** | **1526** | 14 protocol modules | --- @@ -62,7 +62,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 444 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 432 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -81,7 +81,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`error-code-ledger.zod.ts`](/docs/references/api/error-code-ledger) | `ErrorCode`, `ProvenanceWaiver`, `StandardSynonymWaiver` | | [`errors.zod.ts`](/docs/references/api/errors) | `EnhancedApiError`, `ErrorCategory`, `ErrorResponse`, `FieldError`, `FieldErrorCode`, `RetryStrategy`, `StandardErrorCode` | | [`events.zod.ts`](/docs/references/api/events) | `BulkDataEvent`, `BulkDataEventType`, `DataEvent`, `DataEventType`, `MetadataEvent`, `MetadataEventType` | -| [`export.zod.ts`](/docs/references/api/export) | `CreateExportJobRequest`, `CreateExportJobResponse`, `CreateImportJobRequest`, `CreateImportJobResponse`, `DeduplicationStrategy`, `ExportFormat`, `ExportImportTemplate`, `ExportJobProgress`, `ExportJobStatus`, `ExportJobSummary`, `FieldMappingEntry`, `GetExportJobDownloadRequest`, `GetExportJobDownloadResponse`, `ImportJobProgress`, `ImportJobResults`, `ImportJobStatus`, `ImportJobSummary`, `ImportMapping`, `ImportRequest`, `ImportResponse`, `ImportRowResult`, `ImportValidationConfig`, `ImportValidationMode`, `ImportValidationResult`, `ImportWriteMode`, `ListExportJobsRequest`, `ListExportJobsResponse`, `ListImportJobsRequest`, `ListImportJobsResponse`, `ScheduleExportRequest`, `ScheduleExportResponse`, `ScheduledExport`, `UndoImportJobResponse` | +| [`export.zod.ts`](/docs/references/api/export) | `CreateImportJobRequest`, `CreateImportJobResponse`, `DeduplicationStrategy`, `ExportFormat`, `ExportImportTemplate`, `FieldMappingEntry`, `ImportJobProgress`, `ImportJobResults`, `ImportJobStatus`, `ImportJobSummary`, `ImportMapping`, `ImportRequest`, `ImportResponse`, `ImportRowResult`, `ImportValidationConfig`, `ImportValidationMode`, `ImportValidationResult`, `ImportWriteMode`, `ListImportJobsRequest`, `ListImportJobsResponse`, `UndoImportJobResponse` | | [`http-cache.zod.ts`](/docs/references/api/http-cache) | `CacheControl`, `CacheDirective`, `CacheInvalidationRequest`, `CacheInvalidationResponse`, `CacheInvalidationTarget`, `ETag`, `MetadataCacheRequest`, `MetadataCacheResponse` | | [`metadata.zod.ts`](/docs/references/api/metadata) | `AppDefinitionResponse`, `ConceptListResponse`, `MetadataBulkRegisterRequest`, `MetadataBulkResponse`, `MetadataBulkUnregisterRequest`, `MetadataDeleteResponse`, `MetadataDependenciesResponse`, `MetadataDependentsResponse`, `MetadataExistsResponse`, `MetadataExportRequest`, `MetadataExportResponse`, `MetadataImportRequest`, `MetadataImportResponse`, `MetadataItemResponse`, `MetadataListResponse`, `MetadataNamesResponse`, `MetadataQueryRequest`, `MetadataQueryResponse`, `MetadataRegisterRequest`, `MetadataTypeInfoResponse`, `MetadataTypesResponse`, `MetadataValidateRequest`, `MetadataValidateResponse`, `ObjectDefinitionResponse` | | [`misc`](/docs/references/api/misc) *(no single source file)* | `ResolvedBook`, `ResolvedEntry`, `ResolvedGroup` | @@ -105,7 +105,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. ## Automation Protocol -**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 76 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. @@ -115,7 +115,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu | [`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`, `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`, `ScheduleState` | +| [`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` | | [`flow-function.zod.ts`](/docs/references/automation/flow-function) | `FlowFunctionEffect`, `FlowFunctionLoweredDeclaration` | | [`io-node-config.zod.ts`](/docs/references/automation/io-node-config) | `HttpConfig`, `NotifyConfig` | diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index bdbef6f983c..442963f4e48 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -212,9 +212,6 @@ "STRUCTURAL_CONDITION_SHAPE_REFUSAL (const)", "ScheduleOrganization (type)", "ScheduleOrganizationSchema (const)", - "ScheduleState (type)", - "ScheduleStateParsed (type)", - "ScheduleStateSchema (const)", "SchemalessNodeType (type)", "ScreenConfig (type)", "ScreenConfigParsed (type)", diff --git a/packages/spec/declaration-map/automation.json b/packages/spec/declaration-map/automation.json index 5d400e7713e..2e35ecc966a 100644 --- a/packages/spec/declaration-map/automation.json +++ b/packages/spec/declaration-map/automation.json @@ -111,8 +111,6 @@ "ParallelConfigSchema": "automation/ParallelConfig", "ScheduleOrganization": "automation/ScheduleOrganization", "ScheduleOrganizationSchema": "automation/ScheduleOrganization", - "ScheduleState": "automation/ScheduleState", - "ScheduleStateSchema": "automation/ScheduleState", "ScreenConfig": "automation/ScreenConfig", "ScreenConfigSchema": "automation/ScreenConfig", "ScreenFieldConfig": "automation/ScreenFieldConfig", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 4063aa9969e..0f7d1d19263 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -207,9 +207,6 @@ "STRUCTURAL_CONDITION_SHAPE_REFUSAL": "src/automation/flow-node-expression-paths.ts#STRUCTURAL_CONDITION_SHAPE_REFUSAL (const)", "ScheduleOrganization": "src/automation/schedule-organization.zod.ts#ScheduleOrganization (type)", "ScheduleOrganizationSchema": "src/automation/schedule-organization.zod.ts#ScheduleOrganizationSchema (const)", - "ScheduleState": "src/automation/execution.zod.ts#ScheduleState (type)", - "ScheduleStateParsed": "src/automation/execution.zod.ts#ScheduleStateParsed (type)", - "ScheduleStateSchema": "src/automation/execution.zod.ts#ScheduleStateSchema (const)", "SchemalessNodeType": "src/automation/schemaless-node-config.zod.ts#SchemalessNodeType (type)", "ScreenConfig": "src/automation/builtin-node-config.zod.ts#ScreenConfig (type)", "ScreenConfigParsed": "src/automation/builtin-node-config.zod.ts#ScreenConfigParsed (type)", diff --git a/packages/spec/json-schema.manifest/automation.json b/packages/spec/json-schema.manifest/automation.json index 2e804da5f41..76c8a15aa24 100644 --- a/packages/spec/json-schema.manifest/automation.json +++ b/packages/spec/json-schema.manifest/automation.json @@ -60,7 +60,6 @@ "automation/ParallelConfig", "automation/RetryPolicy", "automation/ScheduleOrganization", - "automation/ScheduleState", "automation/ScreenConfig", "automation/ScreenFieldConfig", "automation/ScriptConfig", From 7af0516c33d920eb82afb49a594ad46cf82e9c56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:51:02 +0000 Subject: [PATCH 4/4] chore(spec): regenerate the references index after merging main again The second merge of main took main's side of content/docs/references/index.mdx (both sides changed it) and resolved the hand-edited dropped-refinements ledger: this branch's three entries on top of main's three new $ne sites, with the header totals recounted from the body (210 schemas, 580 sites). This commit regenerates the index from the merged sources; against main it differs only by this branch's +1 schema (FlowValueSlot). Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- content/docs/references/index.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index fa305984be4..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 — 1526 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/. */} @@ -20,7 +20,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 32 | 432 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 429 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [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. | @@ -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** | **1526** | 14 protocol modules | +| **Total** | **196** | **1523** | 14 protocol modules | --- @@ -62,7 +62,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 432 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 429 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -71,7 +71,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`analytics.zod.ts`](/docs/references/api/analytics) | `AnalyticsEndpoint`, `AnalyticsMetadataResponse`, `AnalyticsQueryRequest`, `AnalyticsResultResponse`, `AnalyticsSqlResponse`, `DatasetCompareTo`, `DatasetSelection`, `DatasetTotals`, `GetAnalyticsMetaRequest` | | [`auth.zod.ts`](/docs/references/api/auth) | `AuthProvider`, `LoginRequest`, `LoginType`, `RefreshTokenRequest`, `RegisterRequest`, `Session`, `SessionResponse`, `SessionUser`, `UserProfileResponse` | | [`auth-endpoints.zod.ts`](/docs/references/api/auth-endpoints) | `AuthEndpoint`, `AuthFeaturesConfig`, `AuthProviderInfo`, `DeviceRequestResponse`, `DeviceTokenResponse`, `EmailPasswordConfigPublic`, `GetAuthConfigResponse` | -| [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowSummary`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListFlowsRequest`, `ListFlowsResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` | +| [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` | | [`batch.zod.ts`](/docs/references/api/batch) | `BatchConfig`, `BatchOperationResult`, `BatchOperationType`, `BatchOptions`, `BatchRecord`, `BatchUpdateRequest`, `BatchUpdateResponse`, `CrossObjectBatchDroppedFields`, `CrossObjectBatchOperation`, `CrossObjectBatchRequest`, `CrossObjectBatchResponse`, `DeleteManyRequest`, `UpdateManyRecord`, `UpdateManyRequest` | | [`contract.zod.ts`](/docs/references/api/contract) | `ApiError`, `BaseResponse`, `BatchLoadingStrategy`, `BulkRequest`, `BulkResponse`, `CreateRequest`, `DataLoaderConfig`, `DeleteResponse`, `ExportRequest`, `IdRequest`, `ListRecordResponse`, `ModificationResult`, `QueryOptimizationConfig`, `RecordData`, `SingleRecordResponse`, `UpdateRequest` | | [`discovery.zod.ts`](/docs/references/api/discovery) | `ApiRoutes`, `CapabilityDescriptor`, `Discovery`, `DiscoveryEnvironment`, `EnvironmentType`, `RouteHealthEntry`, `RouteHealthReport`, `ServiceInfo`, `ServiceSelfInfo`, `ServiceStatus`, `WellKnownCapabilities` |