diff --git a/.changeset/assignment-value-role-cel-envelope.md b/.changeset/assignment-value-role-cel-envelope.md
new file mode 100644
index 0000000000..ec63f73225
--- /dev/null
+++ b/.changeset/assignment-value-role-cel-envelope.md
@@ -0,0 +1,42 @@
+---
+"@objectstack/spec": minor
+---
+
+feat(spec): an `assignment` value may be a CEL value envelope — the expression ledger gains the `value` role
+
+An assignment's whole job is to compute a value into a variable, yet CEL was
+reachable from flow metadata only where the answer had to be a boolean
+(`condition`, `decision.conditions[].expression`, a screen field's
+`visibleWhen`): `FLOW_NODE_EXPRESSION_PATHS` declared two roles, `predicate`
+and `flow-template`, and the `assignment` node's values were `{token}`
+interpolation only. So the stdlib the platform already declares, documents and
+tests — `joinNonEmpty` and the rest of `CEL_STDLIB_FUNCTIONS` — could not be
+called from metadata, and the commonest outbound shape a business application
+has (one digest message listing a recipient's N records) needed a `script`
+node.
+
+Ruled (maintainer, 2026-09-02): the rendering half only, no new vocabulary.
+
+- `FlowNodeExpressionRole` gains `'value'`: a slot whose authored value may be a
+ `{ dialect: 'cel', source }` expression envelope evaluated by the expression
+ engine to the value the variable takes — not a predicate, not a template.
+ The ledger gains the `assignment` entry at `assignments.*`; ledger paths now
+ accept a `*` segment ("every key of this object", the sibling of `[]`), and
+ `resolveFlowNodeExpressions` emits only envelope-shaped objects for a `value`
+ slot — a plain string there stays `{token}` interpolation. Every entry that
+ existed before resolves byte-identically. `isExpressionEnvelopeShaped` is
+ the exported recognizer both halves discriminate on.
+- `AssignmentConfigSchema` / `AssignmentValueSchema` /
+ `AssignmentExpressionValueSchema` declare the `assignment` node's value
+ contract: a string (`{token}` interpolation), a CEL value envelope (the
+ `ExpressionSchema` spelling, narrowed to the `cel` dialect), or any other
+ literal. Every value that parsed before still parses; the one newly refused
+ shape is a malformed envelope (no `source`, an empty or non-string `source`,
+ a `template` / `cron` / unknown dialect), refused at the variable's path with
+ a fixed leading sentence. The map value carries `.meta({ xExpression:
+ 'value' })`, the declaration channel the ledger reads for this slot.
+
+The executor half is a separate change in `@objectstack/service-automation`:
+until it lands, the built-in `assignment` executor still writes an envelope
+object into the variable verbatim and `notify` renders it as JSON, and the
+ledger's reconciliation ratchet there does not yet know the `value` marker.
diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx
index 7f50b218ff..682e61a7fc 100644
--- a/content/docs/automation/flows.mdx
+++ b/content/docs/automation/flows.mdx
@@ -176,6 +176,47 @@ or missing-`required` violation (#4277). A node type that publishes no
}
```
+**Assignment (set variables):**
+
+```typescript
+{
+ id: 'build_digest',
+ type: 'assignment',
+ label: 'Build digest',
+ config: {
+ assignments: {
+ // `{token}` flow interpolation — a sole token keeps the token's type,
+ // text with holes stays text
+ owner_name: '{manager.name}',
+ // CEL value envelope — evaluated by the expression engine to a value, so
+ // the declared CEL stdlib is reachable from metadata: one line per task
+ digest: { dialect: 'cel', source: 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")' },
+ },
+ },
+}
+```
+
+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 or empty `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}'`.
+
+
+
+The envelope form is what `@objectstack/spec` declares — the expression
+ledger's `value` entry and `AssignmentConfigSchema`, the half ruled in
+[#14149]. Until the matching `@objectstack/service-automation` change lands,
+the built-in `assignment` executor still writes an envelope object into the
+variable verbatim and `notify` renders it as JSON. For a digest body today,
+call a registered function from a `script` node.
+
+[#14149]: https://github.com/objectstack-ai/objectstack/issues/14149
+
+
+
**Create Record:**
```typescript
diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx
index 463e2b89fd..c700aa2556 100644
--- a/content/docs/references/automation/builtin-node-config.mdx
+++ b/content/docs/references/automation/builtin-node-config.mdx
@@ -7,8 +7,9 @@ description: Builtin Node Config protocol schemas
Config contracts for the remaining flat builtins — the CRUD quartet
(`get_record` / `create_record` / `update_record` / `delete_record`),
-`screen`, and `map` (#4045). Sibling of `io-node-config.zod.ts`
-(notify / http) and `control-flow.zod.ts` (loop / parallel / try_catch).
+`screen`, `map` (#4045) and, since #14149, `assignment`'s value contract.
+Sibling of `io-node-config.zod.ts` (notify / http) and `control-flow.zod.ts`
+(loop / parallel / try_catch).
## Provenance — written from the executors, not from the forms
@@ -58,11 +59,15 @@ finding is that a bespoke guard's detection generalizes for free the moment
a default flips, while its PROSE does not, so the prose is copied to the new
door rather than left behind at the old one.
+`assignment` is described by its VALUES, not by a key set (#14149): the
+canonical `assignments` map's keys are the author's variable names, and with
+no `assignments` wrapper the TOP-LEVEL config keys are (logic-nodes.ts), so
+`AssignmentConfigSchema` below declares one key and an open catchall and puts
+the contract on what a value may be. The form↔Zod ledger test still pins the
+descriptor's free-form `assignments` map as the openness it is; that pin and
+this contract describe the same surface from the two sides.
+
Deliberately absent:
- - `assignment` — its config cannot be described by a fixed key set: with no
- `assignments` wrapper the TOP-LEVEL config keys ARE the author's variable
- names (logic-nodes.ts). The ledger test pins that exemption with its
- reason instead of pretending a shape.
- `decision` / `script` / `subflow` / `wait` / `connector_action` — the
descriptor-schemaless class (config-schemas.test.ts). `wait` and
`connector_action` keep their contracts in FlowNodeSchema's sibling
@@ -79,13 +84,47 @@ Deliberately absent:
## TypeScript Usage
```typescript
-import { CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
-import type { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
+import { AssignmentConfigSchema, AssignmentExpressionValueSchema, AssignmentValueSchema, CreateRecordConfigSchema, DeleteRecordConfigSchema, GetRecordConfigSchema, MapConfigSchema, ScreenConfigSchema, ScreenFieldConfigSchema, UpdateRecordConfigSchema } from '@objectstack/spec/automation';
+import type { AssignmentConfig, AssignmentExpressionValue, AssignmentValue, CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
// Validate data
-const result = CreateRecordConfigSchema.parse(data);
+const result = AssignmentConfigSchema.parse(data);
```
+---
+
+## AssignmentConfig
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **assignments** | `Record` | optional | Variables to set: each key is a variable name, each value a `{token}` template, a CEL value envelope, or a literal |
+
+
+---
+
+## 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
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **dialect** | `'cel'` | ✅ | |
+| **source** | `string` | optional | |
+| **ast** | `any` | optional | |
+| **meta** | `{ rationale?: string; generatedBy?: string }` | optional | |
+
+
+---
+
+## AssignmentValue
+
+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
+
+
---
## CreateRecordConfig
diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx
index 47b1e23769..7368846594 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 — 1589 schemas across 14 protocol modules
+description: Every schema published by @objectstack/spec — 1592 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) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
| [API Protocol](/docs/references/api) | 31 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. |
-| [Automation Protocol](/docs/references/automation) | 13 | 69 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
+| [Automation Protocol](/docs/references/automation) | 13 | 72 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. |
| [Data Protocol](/docs/references/data) | 29 | 166 | 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) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
-| **Total** | **199** | **1589** | 14 protocol modules |
+| **Total** | **199** | **1592** | 14 protocol modules |
---
@@ -103,7 +103,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery.
## Automation Protocol
-**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 69 schemas**
+**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 72 schemas**
Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records.
@@ -111,7 +111,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) | `CreateRecordConfig`, `DeleteRecordConfig`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` |
+| [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `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/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
index 1bcbe3a94b..238d5c6684 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
@@ -21,7 +21,7 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
-| Object sites in them | 439 |
+| Object sites in them | 440 |
| Still-open (strip) sites | 124 |
| Files carrying at least one | 22 |
@@ -46,10 +46,10 @@ The `strict` column is the one the campaign schedules against; it counts both th
|---|---|---|---|---|---|
| `ui/` | 169 | 157 | 5 | 0 | 7 |
| `data/` | 157 | 76 | 1 | 0 | 80 |
-| `automation/` | 66 | 42 | 0 | 0 | 24 |
+| `automation/` | 67 | 42 | 0 | 1 | 24 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
-| **total** | **439** | **309** | **6** | **0** | **124** |
+| **total** | **440** | **309** | **6** | **1** | **124** |
## File-level triage — site counts
@@ -115,7 +115,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
|---|---|
| `approval.zod.ts` | 4 |
| `bpmn-interop.zod.ts` | 5 |
-| `builtin-node-config.zod.ts` | 8 |
+| `builtin-node-config.zod.ts` | 9 |
| `control-flow.zod.ts` | 6 |
| `execution.zod.ts` | 13 |
| `flow-function.zod.ts` | 1 |
@@ -126,7 +126,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `state-machine.zod.ts` | 6 |
| `time-relative-trigger.zod.ts` | 1 |
| `webhook.zod.ts` | 1 |
-| **total** | **66** |
+| **total** | **67** |
### `security/` — sites
@@ -204,7 +204,7 @@ over it is here.
### `automation/` — open
-**24 strip of 66**, in 5 file(s).
+**24 strip of 67**, in 5 file(s).
| File | Strip | Sites |
|---|---|---|
@@ -213,7 +213,7 @@ over it is here.
| `execution.zod.ts` | 13 | 13 |
| `flow.zod.ts` | 1 | 11 |
| `node-executor.zod.ts` | 4 | 4 |
-| **total** | **24** | **66** |
+| **total** | **24** | **67** |
| Bucket | Sites |
|---|---|
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 4f897fe49a..cef90e3dc9 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
@@ -46,6 +46,8 @@ interface SchemaNode {
type?: string;
properties?: Record;
items?: SchemaNode;
+ /** `true` = an open map with untyped values; an object = the schema every value takes. */
+ additionalProperties?: boolean | SchemaNode;
xExpression?: string;
}
@@ -57,15 +59,26 @@ interface SchemaNode {
* enforces the ADR-0032 §3 double-brace text template, a different dialect that
* rejects a single `{x}`. Conflating the two would fail every valid
* `loop.collection`.
+ *
+ * `'value'` (#14149) maps to the ledger's `value` role: a slot whose authored
+ * value may be a `{ dialect: 'cel', source }` envelope evaluated by the
+ * expression engine to a value — the `assignment` node's `assignments.*`. It
+ * IS `validateExpression`'s `'value'` role (CEL, any result type); what makes
+ * it a distinct ledger role is the slot's shape rule: only an envelope-shaped
+ * object is an expression there, a plain string stays `{var}` interpolation.
*/
const ROLE_BY_MARKER: Record = {
expression: 'predicate',
template: 'flow-template',
+ value: 'value',
};
/**
* Collect every `xExpression`-marked property in a configSchema, as the same
- * `key[].nested` path syntax the ledger uses.
+ * `key[].nested` path syntax the ledger uses — and, since #14149, the same
+ * `key.*` syntax: an object-valued `additionalProperties` is the schema every
+ * value of an open map takes, so a marker there declares "every authored key
+ * of this map", the sibling of `items` for arrays.
*/
function collectExpressionProps(
schema: SchemaNode | undefined,
@@ -88,6 +101,18 @@ function collectExpressionProps(
else out.push(...collectExpressionProps(prop, here));
}
}
+
+ // An open map whose VALUES carry a schema contributes `key.*` — the ledger's
+ // spelling for "every authored key of this map" (`assignments.*`). A bare
+ // `additionalProperties: true` (untyped values — the `assignment`
+ // DESCRIPTOR's own shape) declares nothing, so the descriptor channel and
+ // the spec channel cannot double-declare the same slot.
+ const values = schema.additionalProperties;
+ if (values && typeof values === 'object') {
+ const here = prefix ? `${prefix}.*` : '*';
+ if (typeof values.xExpression === 'string') out.push({ path: here, marker: values.xExpression });
+ out.push(...collectExpressionProps(values, here));
+ }
return out;
}
@@ -203,6 +228,23 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => {
expect(declaredFromDescriptors().map(key)).not.toContain(key(decision!));
});
+ it('assignment.assignments.* is covered — the #14149 value slot, declared where the descriptor cannot carry it', () => {
+ const assignment = FLOW_NODE_EXPRESSION_PATHS.find(
+ (e) => e.nodeType === 'assignment' && e.path === 'assignments.*',
+ );
+ expect(assignment, 'the ruled slot: an assignment value may be a CEL envelope').toBeDefined();
+ expect(assignment!.role).toBe('value');
+ // The descriptor declares `assignments` as `additionalProperties: true`
+ // (the openness IS its contract, pinned by the form↔Zod ledger), so the
+ // marker rides the spec Zod's map value and reaches this ratchet through
+ // the JSON map — never through the descriptor channel.
+ expect(declaredFromSchemalessConfigs().map(key)).toContain(key(assignment!));
+ expect(declaredFromDescriptors().map(key)).not.toContain(key(assignment!));
+ // And the marker is mapped, not merely tolerated: an unknown marker still
+ // fails `roleOf`, and a ledger entry with no declaration still reads stale.
+ expect(ROLE_BY_MARKER.value).toBe('value');
+ });
+
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 6979159a78..df0e204a82 100644
--- a/packages/spec/api-surface/automation.json
+++ b/packages/spec/api-surface/automation.json
@@ -10,6 +10,8 @@
"APPROVER_ORG_SYMBOLS (const)",
"APPROVER_VALUE_BINDINGS (const)",
"APPROVER_VALUE_SOURCES (const)",
+ "ASSIGNMENT_ARRAY_FORM_PRESCRIPTION (const)",
+ "ASSIGNMENT_VALUE_ENVELOPE_REFUSAL (const)",
"ActionCategory (type)",
"ActionCategorySchema (const)",
"ActionDescriptor (type)",
@@ -31,6 +33,15 @@
"ApproverOrgSymbol (type)",
"ApproverType (type)",
"ApproverValueBinding (type)",
+ "AssignmentConfig (type)",
+ "AssignmentConfigParsed (type)",
+ "AssignmentConfigSchema (const)",
+ "AssignmentExpressionValue (type)",
+ "AssignmentExpressionValueParsed (type)",
+ "AssignmentExpressionValueSchema (const)",
+ "AssignmentValue (type)",
+ "AssignmentValueParsed (type)",
+ "AssignmentValueSchema (const)",
"BPMN_BOUNDARY_EVENT (const)",
"BPMN_JOIN_GATEWAY (const)",
"BPMN_PARALLEL_GATEWAY (const)",
@@ -145,8 +156,10 @@
"HttpConfig (type)",
"HttpConfigParsed (type)",
"HttpConfigSchema (const)",
+ "LEDGER_DECLARED_NODE_CONFIG_SCHEMAS (const)",
"LOOP_MAX_ITERATIONS_CEILING (const)",
"LOOP_NODE_TYPE (const)",
+ "LedgerDeclaredNodeType (type)",
"LoopConfig (type)",
"LoopConfigParsed (type)",
"LoopConfigSchema (const)",
@@ -171,6 +184,7 @@
"ParallelConfig (type)",
"ParallelConfigParsed (type)",
"ParallelConfigSchema (const)",
+ "ReconciledNodeConfigType (type)",
"RegionAnalysis (interface)",
"ResolvedFlowNodeExpression (interface)",
"RetryPolicy (type)",
@@ -240,6 +254,7 @@
"getApprovalNodeConfigJsonSchema (function)",
"getSchemalessNodeConfigJsonSchemas (function)",
"importBpmnToConstructs (function)",
+ "isExpressionEnvelopeShaped (function)",
"isFlowFunctionEffect (function)",
"normalizeDecisionOutputs (function)",
"normalizeFlowFunctionEntry (function)",
diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json
index a3094a5904..4b6c812947 100644
--- a/packages/spec/authorable-surface/automation.json
+++ b/packages/spec/authorable-surface/automation.json
@@ -40,6 +40,11 @@
"automation/ApprovalNodeConfig:maxRevisions",
"automation/ApprovalNodeConfig:minApprovals",
"automation/ApprovalNodeConfig:onEmptyApprovers",
+ "automation/AssignmentConfig:assignments",
+ "automation/AssignmentExpressionValue:ast",
+ "automation/AssignmentExpressionValue:dialect",
+ "automation/AssignmentExpressionValue:meta",
+ "automation/AssignmentExpressionValue:source",
"automation/BpmnDiagnostic:bpmnElementId",
"automation/BpmnDiagnostic:message",
"automation/BpmnDiagnostic:nodeId",
diff --git a/packages/spec/declaration-map/automation.json b/packages/spec/declaration-map/automation.json
index 0b07411375..caa2ec3c05 100644
--- a/packages/spec/declaration-map/automation.json
+++ b/packages/spec/declaration-map/automation.json
@@ -18,6 +18,12 @@
"ApprovalNodeConfig": "automation/ApprovalNodeConfig",
"ApprovalNodeConfigSchema": "automation/ApprovalNodeConfig",
"ApproverType": "automation/ApproverType",
+ "AssignmentConfig": "automation/AssignmentConfig",
+ "AssignmentConfigSchema": "automation/AssignmentConfig",
+ "AssignmentExpressionValue": "automation/AssignmentExpressionValue",
+ "AssignmentExpressionValueSchema": "automation/AssignmentExpressionValue",
+ "AssignmentValue": "automation/AssignmentValue",
+ "AssignmentValueSchema": "automation/AssignmentValue",
"BpmnDiagnostic": "automation/BpmnDiagnostic",
"BpmnDiagnosticSchema": "automation/BpmnDiagnostic",
"BpmnElementMapping": "automation/BpmnElementMapping",
diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json
index 7378e411dd..95da3dce08 100644
--- a/packages/spec/export-origins/automation.json
+++ b/packages/spec/export-origins/automation.json
@@ -10,6 +10,8 @@
"APPROVER_ORG_SYMBOLS": "src/automation/approval.zod.ts#APPROVER_ORG_SYMBOLS (const)",
"APPROVER_VALUE_BINDINGS": "src/automation/approval.zod.ts#APPROVER_VALUE_BINDINGS (const)",
"APPROVER_VALUE_SOURCES": "src/automation/approval.zod.ts#APPROVER_VALUE_SOURCES (const)",
+ "ASSIGNMENT_ARRAY_FORM_PRESCRIPTION": "src/automation/builtin-node-config.zod.ts#ASSIGNMENT_ARRAY_FORM_PRESCRIPTION (const)",
+ "ASSIGNMENT_VALUE_ENVELOPE_REFUSAL": "src/automation/builtin-node-config.zod.ts#ASSIGNMENT_VALUE_ENVELOPE_REFUSAL (const)",
"ActionCategory": "src/automation/node-executor.zod.ts#ActionCategory (type)",
"ActionCategorySchema": "src/automation/node-executor.zod.ts#ActionCategorySchema (const)",
"ActionDescriptor": "src/automation/node-executor.zod.ts#ActionDescriptor (type)",
@@ -31,6 +33,15 @@
"ApproverOrgSymbol": "src/automation/approval.zod.ts#ApproverOrgSymbol (type)",
"ApproverType": "src/automation/approval.zod.ts#ApproverType (type)",
"ApproverValueBinding": "src/automation/approval.zod.ts#ApproverValueBinding (type)",
+ "AssignmentConfig": "src/automation/builtin-node-config.zod.ts#AssignmentConfig (type)",
+ "AssignmentConfigParsed": "src/automation/builtin-node-config.zod.ts#AssignmentConfigParsed (type)",
+ "AssignmentConfigSchema": "src/automation/builtin-node-config.zod.ts#AssignmentConfigSchema (const)",
+ "AssignmentExpressionValue": "src/automation/builtin-node-config.zod.ts#AssignmentExpressionValue (type)",
+ "AssignmentExpressionValueParsed": "src/automation/builtin-node-config.zod.ts#AssignmentExpressionValueParsed (type)",
+ "AssignmentExpressionValueSchema": "src/automation/builtin-node-config.zod.ts#AssignmentExpressionValueSchema (const)",
+ "AssignmentValue": "src/automation/builtin-node-config.zod.ts#AssignmentValue (type)",
+ "AssignmentValueParsed": "src/automation/builtin-node-config.zod.ts#AssignmentValueParsed (type)",
+ "AssignmentValueSchema": "src/automation/builtin-node-config.zod.ts#AssignmentValueSchema (const)",
"BPMN_BOUNDARY_EVENT": "src/automation/bpmn-mapping.ts#BPMN_BOUNDARY_EVENT (const)",
"BPMN_JOIN_GATEWAY": "src/automation/bpmn-mapping.ts#BPMN_JOIN_GATEWAY (const)",
"BPMN_PARALLEL_GATEWAY": "src/automation/bpmn-mapping.ts#BPMN_PARALLEL_GATEWAY (const)",
@@ -145,8 +156,10 @@
"HttpConfig": "src/automation/io-node-config.zod.ts#HttpConfig (type)",
"HttpConfigParsed": "src/automation/io-node-config.zod.ts#HttpConfigParsed (type)",
"HttpConfigSchema": "src/automation/io-node-config.zod.ts#HttpConfigSchema (const)",
+ "LEDGER_DECLARED_NODE_CONFIG_SCHEMAS": "src/automation/schemaless-node-config.zod.ts#LEDGER_DECLARED_NODE_CONFIG_SCHEMAS (const)",
"LOOP_MAX_ITERATIONS_CEILING": "src/automation/control-flow.zod.ts#LOOP_MAX_ITERATIONS_CEILING (const)",
"LOOP_NODE_TYPE": "src/automation/control-flow.zod.ts#LOOP_NODE_TYPE (const)",
+ "LedgerDeclaredNodeType": "src/automation/schemaless-node-config.zod.ts#LedgerDeclaredNodeType (type)",
"LoopConfig": "src/automation/control-flow.zod.ts#LoopConfig (type)",
"LoopConfigParsed": "src/automation/control-flow.zod.ts#LoopConfigParsed (type)",
"LoopConfigSchema": "src/automation/control-flow.zod.ts#LoopConfigSchema (const)",
@@ -171,6 +184,7 @@
"ParallelConfig": "src/automation/control-flow.zod.ts#ParallelConfig (type)",
"ParallelConfigParsed": "src/automation/control-flow.zod.ts#ParallelConfigParsed (type)",
"ParallelConfigSchema": "src/automation/control-flow.zod.ts#ParallelConfigSchema (const)",
+ "ReconciledNodeConfigType": "src/automation/schemaless-node-config.zod.ts#ReconciledNodeConfigType (type)",
"RegionAnalysis": "src/automation/control-flow.zod.ts#RegionAnalysis (interface)",
"ResolvedFlowNodeExpression": "src/automation/flow-node-expression-paths.ts#ResolvedFlowNodeExpression (interface)",
"RetryPolicy": "src/shared/retry-policy.zod.ts#RetryPolicy (type)",
@@ -240,6 +254,7 @@
"getApprovalNodeConfigJsonSchema": "src/automation/approval.zod.ts#getApprovalNodeConfigJsonSchema (function)",
"getSchemalessNodeConfigJsonSchemas": "src/automation/schemaless-node-config.zod.ts#getSchemalessNodeConfigJsonSchemas (function)",
"importBpmnToConstructs": "src/automation/bpmn-mapping.ts#importBpmnToConstructs (function)",
+ "isExpressionEnvelopeShaped": "src/automation/flow-node-expression-paths.ts#isExpressionEnvelopeShaped (function)",
"isFlowFunctionEffect": "src/automation/flow-function.zod.ts#isFlowFunctionEffect (function)",
"normalizeDecisionOutputs": "src/automation/approval.zod.ts#normalizeDecisionOutputs (function)",
"normalizeFlowFunctionEntry": "src/automation/flow-function.zod.ts#normalizeFlowFunctionEntry (function)",
diff --git a/packages/spec/json-schema.manifest/automation.json b/packages/spec/json-schema.manifest/automation.json
index 55d92b07d0..85bab7a226 100644
--- a/packages/spec/json-schema.manifest/automation.json
+++ b/packages/spec/json-schema.manifest/automation.json
@@ -11,6 +11,9 @@
"automation/ApprovalNodeApprover",
"automation/ApprovalNodeConfig",
"automation/ApproverType",
+ "automation/AssignmentConfig",
+ "automation/AssignmentExpressionValue",
+ "automation/AssignmentValue",
"automation/BpmnDiagnostic",
"automation/BpmnElementMapping",
"automation/BpmnExportOptions",
diff --git a/packages/spec/src/automation/builtin-node-config.test.ts b/packages/spec/src/automation/builtin-node-config.test.ts
index fc2bb7cca2..7e8133c890 100644
--- a/packages/spec/src/automation/builtin-node-config.test.ts
+++ b/packages/spec/src/automation/builtin-node-config.test.ts
@@ -17,8 +17,13 @@
*/
import { describe, expect, it } from 'vitest';
+import { z } from 'zod';
import {
+ ASSIGNMENT_ARRAY_FORM_PRESCRIPTION,
+ ASSIGNMENT_VALUE_ENVELOPE_REFUSAL,
+ AssignmentConfigSchema,
+ AssignmentExpressionValueSchema,
CreateRecordConfigSchema,
DeleteRecordConfigSchema,
GetRecordConfigSchema,
@@ -27,6 +32,11 @@ import {
ScreenFieldConfigSchema,
UpdateRecordConfigSchema,
} from './builtin-node-config.zod.js';
+import {
+ LEDGER_DECLARED_NODE_CONFIG_SCHEMAS,
+ SCHEMALESS_NODE_CONFIG_SCHEMAS,
+ getSchemalessNodeConfigJsonSchemas,
+} from './schemaless-node-config.zod.js';
interface Parseable { safeParse(v: unknown): { success: boolean; error?: { issues: ReadonlyArray<{ code: string; message: string }> } } }
@@ -264,3 +274,128 @@ describe('MapConfigSchema — strict as of #4001 批 9', () => {
expect(message).toContain('`flowName`');
});
});
+
+// ─── assignment (#14149) ─────────────────────────────────────────────
+
+describe('assignment value contract — a CEL envelope beside `{token}` interpolation (#14149)', () => {
+ const DIGEST_SOURCE = 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")';
+ const DIGEST_ENVELOPE = { dialect: 'cel', source: DIGEST_SOURCE };
+
+ /** Every `custom` issue at exactly `assignments.` (or beneath it). */
+ function envelopeIssues(config: unknown, key: string) {
+ const result = AssignmentConfigSchema.safeParse(config);
+ if (result.success) return [];
+ return result.error!.issues.filter((i) => i.path[0] === 'assignments' && i.path[1] === key);
+ }
+
+ it('accepts a CEL value envelope as a variable value — the ruling\'s `joinNonEmpty` example, verbatim', () => {
+ const result = AssignmentConfigSchema.safeParse({ assignments: { digest: DIGEST_ENVELOPE } });
+ expect(result.success).toBe(true);
+ // No transform: the stored shape IS the envelope `validateExpression` and
+ // the executor half read, so nothing has to un-normalize it later.
+ expect((result.data as { assignments: Record }).assignments.digest).toEqual(DIGEST_ENVELOPE);
+ expect(AssignmentExpressionValueSchema.safeParse(DIGEST_ENVELOPE).success).toBe(true);
+ });
+
+ it('accepts the two forms side by side in one node', () => {
+ expect(AssignmentConfigSchema.safeParse({
+ assignments: { owner_name: '{manager.name}', digest: DIGEST_ENVELOPE },
+ }).success).toBe(true);
+ });
+
+ it('PRESERVATION: every value that parsed before still parses — strings, scalars, arrays, plain objects', () => {
+ expect(AssignmentConfigSchema.safeParse({
+ assignments: {
+ decision: 'approved', // the showcase's own assignment
+ owner: '{record.owner}', // sole-token interpolation
+ greeting: 'Hi {record.name}!', // text with holes
+ cel_looking_text: 'a + b', // a STRING is never CEL here
+ n: 3, ok: true, nothing: null,
+ list: ['{a}', 2],
+ obj: { nested: '{x}', source: 'not an envelope without a dialect' },
+ empty: '',
+ },
+ }).success).toBe(true);
+ // An envelope-shaped object with a non-string `dialect` is a literal, as it always was.
+ expect(AssignmentConfigSchema.safeParse({ assignments: { weird: { dialect: 1 } } }).success).toBe(true);
+ // An empty node is valid (the descriptor declares no `required`).
+ expect(AssignmentConfigSchema.safeParse({}).success).toBe(true);
+ expect(AssignmentConfigSchema.safeParse({ assignments: {} }).success).toBe(true);
+ });
+
+ it('bare legacy keys (no `assignments` wrapper) still parse, untouched — an envelope there is a literal', () => {
+ expect(AssignmentConfigSchema.safeParse({ decision: 'approved', digest: { dialect: 'cel' } }).success).toBe(true);
+ });
+
+ it.each([
+ ['no `source` (and no `ast`)', { dialect: 'cel' }, ['assignments', 'digest'], 'Expression requires at least one of'],
+ ['an empty `source`', { dialect: 'cel', source: '' }, ['assignments', 'digest', 'source'], ''],
+ ['a non-string `source`', { dialect: 'cel', source: 42 }, ['assignments', 'digest', 'source'], ''],
+ ['an unknown dialect', { dialect: 'javascript', source: '1 + 1' }, ['assignments', 'digest', 'dialect'], ''],
+ ] as ReadonlyArray<[string, unknown, ReadonlyArray, string]>)(
+ 'REFUSES a malformed envelope with %s — code `custom`, the value\'s path, the refusal sentence first',
+ (_what, envelope, path, detail) => {
+ const issues = envelopeIssues({ assignments: { digest: envelope } }, 'digest');
+ expect(issues.length).toBeGreaterThan(0);
+ const issue = issues.find((i) => i.path.join('.') === path.join('.'))!;
+ expect(issue, `an issue at ${path.join('.')}`).toBeDefined();
+ expect(issue.code).toBe('custom');
+ expect(issue.message.startsWith(ASSIGNMENT_VALUE_ENVELOPE_REFUSAL)).toBe(true);
+ if (detail) expect(issue.message).toContain(detail);
+ },
+ );
+
+ it('REFUSES a `template` / `cron` envelope: only `cel` is evaluated to a value', () => {
+ for (const dialect of ['template', 'cron']) {
+ const issues = envelopeIssues({ assignments: { body: { dialect, source: 'x' } } }, 'body');
+ expect(issues.map((i) => i.path.join('.'))).toEqual(['assignments.body.dialect']);
+ expect(issues[0]!.code).toBe('custom');
+ expect(issues[0]!.message.startsWith(ASSIGNMENT_VALUE_ENVELOPE_REFUSAL)).toBe(true);
+ expect(issues[0]!.message).toContain('only the `cel` dialect');
+ }
+ });
+
+ it('a malformed envelope is refused wherever it sits in the map — the path names the variable', () => {
+ const result = AssignmentConfigSchema.safeParse({
+ assignments: { fine: DIGEST_ENVELOPE, broken: { dialect: 'cel' }, alsoFine: '{x}' },
+ });
+ expect(result.success).toBe(false);
+ expect(result.error!.issues.map((i) => i.path.join('.'))).toEqual(['assignments.broken']);
+ });
+
+ it('refuses the legacy array form as a type error carrying the map as the prescription', () => {
+ const result = AssignmentConfigSchema.safeParse({ assignments: [{ variable: 'digest', value: DIGEST_ENVELOPE }] });
+ expect(result.success).toBe(false);
+ expect(result.error!.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_type', 'assignments']]);
+ expect(result.error!.issues[0]!.message).toBe(ASSIGNMENT_ARRAY_FORM_PRESCRIPTION);
+ expect(ASSIGNMENT_ARRAY_FORM_PRESCRIPTION).toContain('`[{ variable, value }]`');
+ // The prescription is scoped to the array: any other wrong type keeps Zod's own message.
+ expect(AssignmentConfigSchema.safeParse({ assignments: 'nope' }).error!.issues[0]!.message)
+ .not.toBe(ASSIGNMENT_ARRAY_FORM_PRESCRIPTION);
+ });
+
+ it('declares the slot to the expression ledger: `xExpression: \'value\'` rides onto the map value', () => {
+ // The channel `FLOW_NODE_EXPRESSION_PATHS`'s `assignment` entry
+ // (`assignments.*`) is derived from: a ratchet walking the map's
+ // `additionalProperties` finds the marker there, the same way it finds
+ // `loop.collection`'s `'template'` marker on a property.
+ const json = z.toJSONSchema(AssignmentConfigSchema, {
+ target: 'draft-2020-12', io: 'input', unrepresentable: 'any',
+ }) as { properties?: Record }> };
+ expect(json.properties?.assignments?.additionalProperties?.xExpression).toBe('value');
+ expect(String(json.properties?.assignments?.additionalProperties?.description)).toContain('joinNonEmpty');
+ });
+
+ it('reaches the reconciliation ratchet through the JSON map it already walks', () => {
+ // `service-automation`'s `config-expression-ledger.test.ts` derives its
+ // expectation from `getSchemalessNodeConfigJsonSchemas()`; `assignment`
+ // 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']);
+ expect(Object.keys(SCHEMALESS_NODE_CONFIG_SCHEMAS).sort()).toEqual(['decision', 'script', 'subflow']);
+ const projected = getSchemalessNodeConfigJsonSchemas().assignment as
+ { properties?: Record }> };
+ expect(projected.properties?.assignments?.additionalProperties?.xExpression).toBe('value');
+ });
+});
diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts
index 1e1995e539..43fddd26c0 100644
--- a/packages/spec/src/automation/builtin-node-config.zod.ts
+++ b/packages/spec/src/automation/builtin-node-config.zod.ts
@@ -5,8 +5,9 @@
*
* Config contracts for the remaining flat builtins — the CRUD quartet
* (`get_record` / `create_record` / `update_record` / `delete_record`),
- * `screen`, and `map` (#4045). Sibling of `io-node-config.zod.ts`
- * (notify / http) and `control-flow.zod.ts` (loop / parallel / try_catch).
+ * `screen`, `map` (#4045) and, since #14149, `assignment`'s value contract.
+ * Sibling of `io-node-config.zod.ts` (notify / http) and `control-flow.zod.ts`
+ * (loop / parallel / try_catch).
*
* ## Provenance — written from the executors, not from the forms
*
@@ -56,11 +57,15 @@
* a default flips, while its PROSE does not, so the prose is copied to the new
* door rather than left behind at the old one.
*
+ * `assignment` is described by its VALUES, not by a key set (#14149): the
+ * canonical `assignments` map's keys are the author's variable names, and with
+ * no `assignments` wrapper the TOP-LEVEL config keys are (logic-nodes.ts), so
+ * `AssignmentConfigSchema` below declares one key and an open catchall and puts
+ * the contract on what a value may be. The form↔Zod ledger test still pins the
+ * descriptor's free-form `assignments` map as the openness it is; that pin and
+ * this contract describe the same surface from the two sides.
+ *
* Deliberately absent:
- * - `assignment` — its config cannot be described by a fixed key set: with no
- * `assignments` wrapper the TOP-LEVEL config keys ARE the author's variable
- * names (logic-nodes.ts). The ledger test pins that exemption with its
- * reason instead of pretending a shape.
* - `decision` / `script` / `subflow` / `wait` / `connector_action` — the
* descriptor-schemaless class (config-schemas.test.ts). `wait` and
* `connector_action` keep their contracts in FlowNodeSchema's sibling
@@ -72,8 +77,10 @@
*/
import { z } from 'zod';
+import { ExpressionSchema } from '../shared/expression.zod';
import { lazySchema } from '../shared/lazy-schema';
import { strictObject } from '../shared/strict-object';
+import { isExpressionEnvelopeShaped } from './flow-node-expression-paths';
/** What a rejected key on these contracts silently did before #4001 批 9. */
const BUILTIN_NODE_CONFIG_HISTORY =
@@ -513,3 +520,166 @@ export const MapConfigSchema = lazySchema(() => strictObject({
export type MapConfig = z.input;
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`
+ * itself, `safeExtend`ed (the form Zod reserves for a refined object, keeping
+ * its `source`-or-`ast` rule) with the one 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 one shape that validator lets through: an envelope
+ * with no `source` reads as "not authored" there (`ok: true`), so this parse
+ * is the only gate that catches `{ dialect: 'cel' }` 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 = ExpressionSchema
+ .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 (`{ dialect: 'cel' }`
+ * with no `source`, an empty `source`, a non-`cel` dialect), which used to be
+ * stored verbatim as a literal object and then rendered by `notify` as JSON.
+ *
+ * `.meta({ xExpression: 'value' })` is the declaration channel the expression
+ * ledger reads for this slot (`FLOW_NODE_EXPRESSION_PATHS`'s `assignment`
+ * entry, `flow-node-expression-paths.ts`): the marker rides `z.toJSONSchema`
+ * onto the map's `additionalProperties` — `AssignmentConfigSchema` is exposed
+ * to the reconciliation ratchet through `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`
+ * (`schemaless-node-config.zod.ts`), and the ratchet walks that position as
+ * the `*` segment, deriving the ledger path `assignments.*`. objectui's
+ * inspector reads `xExpression` on string properties only, so the marker
+ * changes no editor — the keyValue widget stores an envelope typed as JSON in
+ * the value cell.
+ *
+ * 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.
+ */
+export const AssignmentValueSchema = 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: `${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 type AssignmentValue = z.input;
+export type AssignmentValueParsed = z.infer;
+
+/** What the refusal of the legacy `assignments: [{ variable, value }]` array says. */
+export const ASSIGNMENT_ARRAY_FORM_PRESCRIPTION =
+ '`assignments` is a map of variable name → value (`{ assignments: { total: \'{amount}\' } }`). The array form '
+ + '`[{ variable, value }]` is a legacy shape the executor still reads but this contract does not describe — '
+ + 'write the map, which is also the only shape that accepts a CEL value envelope.';
+
+/**
+ * `assignment` node config — what the executor reads (logic-nodes.ts), from
+ * the value side (#14149).
+ *
+ * The canonical shape is the `assignments` map the descriptor and the Studio
+ * keyValue editor declare: `{ assignments: { : } }`. Its
+ * keys are the author's variable names, so no fixed key set can describe it —
+ * the contract is on the VALUES ({@link AssignmentValueSchema}), and the
+ * expression ledger names the slot as `assignments.*`.
+ *
+ * Two legacy shapes the executor still normalizes are read-compatibility, not
+ * contract: the bare `{ : }` config (no wrapper — the
+ * catchall here accepts it as-is, values untyped, envelope-shaped objects
+ * included as the literals they always were) and the
+ * `assignments: [{ variable, value }]` array, refused below with the map as
+ * the prescription. Neither is offered for new authoring; the envelope form
+ * is a feature of the canonical map only, so a flow that never used it is
+ * unaffected in every shape.
+ *
+ * Not strict: an `assignment` node is exempt from `registerFlow()`'s
+ * undeclared-key walk by design (its top-level keys may be variables), and a
+ * closed shape here would contradict the descriptor's `additionalProperties:
+ * true` the form↔Zod ledger pins.
+ */
+export const AssignmentConfigSchema = lazySchema(() => z.object({
+ /** Variable name → value; the canonical authoring surface. */
+ assignments: z.record(z.string().min(1), AssignmentValueSchema, {
+ // The array form is a TYPE error on this slot; the message is the
+ // prescription, carried on the record's own `invalid_type` issue because
+ // an object-level refinement never runs once a property has failed its
+ // type (Zod aborts the object) — measured, not assumed.
+ error: (issue) => (Array.isArray(issue.input) ? ASSIGNMENT_ARRAY_FORM_PRESCRIPTION : undefined),
+ }).optional()
+ .describe('Variables to set: each key is a variable name, each value a `{token}` template, a CEL value envelope, or a literal'),
+})
+ .catchall(z.unknown()));
+
+export type AssignmentConfig = z.input;
+export type AssignmentConfigParsed = 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
new file mode 100644
index 0000000000..f85cb50da6
--- /dev/null
+++ b/packages/spec/src/automation/flow-node-expression-paths.test.ts
@@ -0,0 +1,193 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `FLOW_NODE_EXPRESSION_PATHS` — the `value` role and the `assignment` entry
+ * (#14149, maintainer ruling 2026-09-02: option A, the rendering half).
+ *
+ * The ledger's two consumers (`service-automation`'s `registerFlow` pass and
+ * `@objectstack/lint`) and its reconciliation ratchet live outside this
+ * package; what THIS file pins is the contract they read: the entry exists
+ * with the ruled role, the `*` wildcard resolves each authored variable's
+ * value, only the envelope form is emitted for the `value` role, and every
+ * entry that existed before resolves byte-identically (the fixtures under
+ * "unchanged" are the ratchet's own cases, restated here so a resolver edit
+ * that moves them fails where the edit is made).
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ FLOW_NODE_EXPRESSION_PATHS,
+ isExpressionEnvelopeShaped,
+ resolveFlowNodeExpressions,
+ type FlowNodeExpressionPath,
+ type FlowNodeExpressionRole,
+} from './flow-node-expression-paths.js';
+
+/** The ruling's example — the declared stdlib, reachable from metadata. */
+const DIGEST_SOURCE = 'joinNonEmpty(overdue_tasks.map(t, t.subject), "\\n")';
+const DIGEST_ENVELOPE = { dialect: 'cel', source: DIGEST_SOURCE };
+
+/** A two-variable assignment: one `{token}` interpolation, one CEL value envelope. */
+const TWO_VARIABLE_ASSIGNMENT = {
+ assignments: {
+ owner_name: '{manager.name}',
+ digest: DIGEST_ENVELOPE,
+ },
+};
+
+describe('FLOW_NODE_EXPRESSION_PATHS — the assignment value entry (#14149)', () => {
+ const entry = FLOW_NODE_EXPRESSION_PATHS.find((e) => e.nodeType === 'assignment');
+
+ it('declares exactly one slot for `assignment`: `assignments.*`, role `value`', () => {
+ expect(entry, 'the ruled entry: an assignment value may be a CEL envelope').toBeDefined();
+ expect(entry!.path).toBe('assignments.*');
+ expect(entry!.role).toBe('value');
+ expect(entry!.label).toBe('assignment value');
+ expect(FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.nodeType === 'assignment')).toHaveLength(1);
+ });
+
+ it('the role union carries `value` beside `predicate` and `flow-template`', () => {
+ // A type-level pin: the union is what downstream consumers switch on.
+ const roles: FlowNodeExpressionRole[] = ['predicate', 'flow-template', 'value'];
+ expect(new Set(FLOW_NODE_EXPRESSION_PATHS.map((e) => e.role))).toEqual(new Set(roles));
+ });
+
+ it('resolves the envelope value of a two-variable assignment, and only it', () => {
+ const found = resolveFlowNodeExpressions('assignment', TWO_VARIABLE_ASSIGNMENT);
+ expect(found).toHaveLength(1);
+ expect(found[0]!.path).toBe('assignments.digest');
+ expect(found[0]!.entry).toBe(entry);
+ expect(found[0]!.entry.role).toBe('value');
+ // The value is handed over verbatim — the envelope, not its source — so a
+ // consumer can pass it straight to `validateExpression('value', envelope)`.
+ expect(found[0]!.value).toBe(DIGEST_ENVELOPE);
+ expect((found[0]!.value as { source: string }).source).toContain('joinNonEmpty(');
+ });
+
+ it('a `{token}` string in a value slot is interpolation, not an expression — skipped', () => {
+ expect(resolveFlowNodeExpressions('assignment', { assignments: { owner_name: '{manager.name}' } })).toEqual([]);
+ // Bare CEL as a STRING is not CEL here either: a plain string has always
+ // meant flow interpolation in this slot, and the envelope is the only CEL
+ // spelling — so `'a + b'` is the literal text `a + b`, and not resolved.
+ expect(resolveFlowNodeExpressions('assignment', { assignments: { sum: 'a + b' } })).toEqual([]);
+ });
+
+ it('literals that are not envelope-shaped are data, not expressions', () => {
+ expect(resolveFlowNodeExpressions('assignment', {
+ assignments: { n: 1, ok: true, nothing: null, list: [1, 2], obj: { source: 'x' } },
+ })).toEqual([]);
+ });
+
+ it('a MALFORMED envelope is still resolved — so the validator refuses it instead of the store keeping it', () => {
+ const found = resolveFlowNodeExpressions('assignment', { assignments: { digest: { dialect: 'cel' } } });
+ expect(found.map((f) => f.path)).toEqual(['assignments.digest']);
+ expect(found[0]!.value).toEqual({ dialect: 'cel' });
+ });
+
+ it('resolves every authored key of the map, in authoring order, with concrete paths', () => {
+ const found = resolveFlowNodeExpressions('assignment', {
+ assignments: {
+ a: { dialect: 'cel', source: '1' },
+ b: '{x}',
+ c: { dialect: 'cel', source: '2' },
+ },
+ });
+ expect(found.map((f) => f.path)).toEqual(['assignments.a', 'assignments.c']);
+ });
+
+ it('the legacy shapes are not declared: bare config keys and the array form resolve nothing', () => {
+ // Bare `{ : }` — envelope-shaped or not, these are the
+ // literal values they always were; the ledger declares the canonical map.
+ expect(resolveFlowNodeExpressions('assignment', { digest: DIGEST_ENVELOPE })).toEqual([]);
+ // `assignments: [{ variable, value }]` — `*` over an array is not a map of
+ // authored keys and must not invent index paths.
+ expect(resolveFlowNodeExpressions('assignment', {
+ assignments: [{ variable: 'digest', value: DIGEST_ENVELOPE }],
+ })).toEqual([]);
+ // The ratchet's own pin, kept true: `config.condition` is structural.
+ expect(resolveFlowNodeExpressions('assignment', { condition: 'a == b' })).toEqual([]);
+ });
+
+ it('a wildcard against a non-object resolves nothing rather than throwing', () => {
+ expect(resolveFlowNodeExpressions('assignment', {})).toEqual([]);
+ expect(resolveFlowNodeExpressions('assignment', { assignments: 'nope' })).toEqual([]);
+ expect(resolveFlowNodeExpressions('assignment', { assignments: null })).toEqual([]);
+ expect(resolveFlowNodeExpressions('assignment', { assignments: 42 })).toEqual([]);
+ expect(resolveFlowNodeExpressions('assignment', null)).toEqual([]);
+ });
+});
+
+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);
+ expect(isExpressionEnvelopeShaped({ dialect: 'cel' })).toBe(true); // malformed, but envelope-SHAPED
+ expect(isExpressionEnvelopeShaped({ dialect: 'nope', source: '1' })).toBe(true); // the validator's call
+ expect(isExpressionEnvelopeShaped({ dialect: 1, source: '1' })).toBe(false);
+ expect(isExpressionEnvelopeShaped({ source: '1' })).toBe(false);
+ expect(isExpressionEnvelopeShaped('{ dialect: cel }')).toBe(false);
+ expect(isExpressionEnvelopeShaped(['dialect'])).toBe(false);
+ expect(isExpressionEnvelopeShaped(null)).toBe(false);
+ expect(isExpressionEnvelopeShaped(undefined)).toBe(false);
+ });
+});
+
+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', () => {
+ 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)',
+ ]);
+ });
+
+ it('screen: each element of a field repeater, with its index', () => {
+ const found = resolveFlowNodeExpressions('screen', {
+ fields: [
+ { name: 'createOpportunity', type: 'boolean' },
+ { name: 'opportunityName', visibleWhen: 'createOpportunity == true' },
+ { name: 'opportunityAmount', visibleWhen: 'createOpportunity == true' },
+ ],
+ });
+ expect(found.map((f) => [f.path, f.value])).toEqual([
+ ['fields[1].visibleWhen', 'createOpportunity == true'],
+ ['fields[2].visibleWhen', 'createOpportunity == true'],
+ ]);
+ expect(found.every((f) => f.entry.role === 'predicate')).toBe(true);
+ });
+
+ it('loop / map: the top-level flow-template slot, still a string', () => {
+ const loop = resolveFlowNodeExpressions('loop', { collection: '{tasks}' });
+ expect(loop.map((f) => [f.path, f.value, f.entry.role])).toEqual([['collection', '{tasks}', 'flow-template']]);
+ const map = resolveFlowNodeExpressions('map', { collection: '{tasks}' });
+ expect(map.map((f) => [f.path, f.value, f.entry.role])).toEqual([['collection', '{tasks}', 'flow-template']]);
+ });
+
+ it('decision: each branch predicate, with its index', () => {
+ const found = resolveFlowNodeExpressions('decision', {
+ conditions: [
+ { label: 'Yes', expression: "lead.status == 'converted'" },
+ { label: 'No', expression: 'true' },
+ ],
+ });
+ expect(found.map((f) => f.path)).toEqual(['conditions[0].expression', 'conditions[1].expression']);
+ expect(found.every((f) => f.entry.role === 'predicate')).toBe(true);
+ });
+
+ it('absent, empty and non-string values in a string-role slot are still skipped', () => {
+ expect(resolveFlowNodeExpressions('screen', {})).toEqual([]);
+ expect(resolveFlowNodeExpressions('screen', { fields: [] })).toEqual([]);
+ expect(resolveFlowNodeExpressions('screen', { fields: [{ visibleWhen: ' ' }] })).toEqual([]);
+ expect(resolveFlowNodeExpressions('screen', { fields: [{ visibleWhen: true }] })).toEqual([]);
+ expect(resolveFlowNodeExpressions('screen', { fields: 'nope' })).toEqual([]);
+ // An ENVELOPE in a predicate-role slot is a non-string — skipped here, a
+ // type violation for the schema pass, exactly as before: the value-role
+ // emission rule is scoped to `value` entries and leaks into no other role.
+ expect(resolveFlowNodeExpressions('loop', { collection: { dialect: 'cel', source: 'x' } })).toEqual([]);
+ expect(resolveFlowNodeExpressions('decision', { condition: 'a == b' })).toEqual([]);
+ });
+});
diff --git a/packages/spec/src/automation/flow-node-expression-paths.ts b/packages/spec/src/automation/flow-node-expression-paths.ts
index b736d65580..d41032685a 100644
--- a/packages/spec/src/automation/flow-node-expression-paths.ts
+++ b/packages/spec/src/automation/flow-node-expression-paths.ts
@@ -94,15 +94,46 @@ export type FlowNodeExpressionRole =
* every existing flow. Recorded here so the reconciliation ratchet still sees
* the marker and a future validator has one place to hook into.
*/
- | 'flow-template';
+ | 'flow-template'
+ /**
+ * A CEL **value** expression — evaluated by the expression engine to the
+ * value the variable takes (`validateExpression`'s `'value'` role: parses as
+ * CEL, any result type). Not a predicate (no boolean expected) and not a
+ * template (no `{token}` holes): the slot's *shape* decides which dialect it
+ * is in. A `value` slot is a config position whose authored value is EITHER
+ * the `{token}` flow interpolation every node string already gets (a plain
+ * string — the `flow-template` dialect above, still unvalidated here) OR an
+ * `{ dialect: 'cel', source }` expression envelope (`ExpressionSchema` in
+ * `shared/expression.zod.ts`) evaluated to a value. Only the envelope form is
+ * an expression to check, so {@link resolveFlowNodeExpressions} emits
+ * envelope-shaped objects — never strings — for this role (#14149).
+ *
+ * Declared for the `assignment` node's `assignments` map (maintainer ruling
+ * 2026-09-02, #14149): that is the one slot whose whole job is to compute a
+ * value into a variable, and until then CEL was reachable only where the
+ * answer had to be a boolean — so the declared stdlib (`joinNonEmpty` and the
+ * rest of `CEL_STDLIB_FUNCTIONS`) was unreachable from metadata. Validated at
+ * `registerFlow` and `objectstack validate` by the executor-side half
+ * (`service-automation` / `lint` consumers call
+ * `validateExpression('value', …)` on what this ledger resolves); the same
+ * half evaluates the envelope at run time. Until it lands the built-in
+ * `assignment` executor writes an envelope object into the variable verbatim
+ * (`interpolate()` recurses into it as a literal object) — the contract is
+ * declared here first so the executor has one shape to implement.
+ */
+ | 'value';
/** One expression-bearing config slot on a builtin flow node type. */
export interface FlowNodeExpressionPath {
/** Registry node type the path belongs to (`node.type`). */
readonly nodeType: string;
/**
- * Dot path into `node.config`, using `[]` for "every element of this array".
- * `'fields[].visibleWhen'` reads `config.fields[i].visibleWhen` for each `i`.
+ * Dot path into `node.config`, using `[]` for "every element of this array"
+ * and a `*` segment for "every key of this object" (#14149).
+ * `'fields[].visibleWhen'` reads `config.fields[i].visibleWhen` for each `i`;
+ * `'assignments.*'` reads `config.assignments[k]` for each authored key `k`
+ * — the spelling for a map whose keys are the author's own names (variable
+ * names, field names) and so cannot be enumerated by the ledger.
*/
readonly path: string;
/** Which dialect this slot takes — decides what counts as malformed. */
@@ -132,6 +163,19 @@ export interface FlowNodeExpressionPath {
* like `loop.collection`. The #4439 sweep of the schemaless class found exactly
* one genuinely declared expression slot — `decision.conditions[].expression` —
* and `script.template` is a template **id**, not a template body.
+ *
+ * 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.
*/
export const FLOW_NODE_EXPRESSION_PATHS: readonly FlowNodeExpressionPath[] = [
{
@@ -161,6 +205,30 @@ export const FLOW_NODE_EXPRESSION_PATHS: readonly FlowNodeExpressionPath[] = [
role: 'flow-template',
label: 'map collection',
},
+ {
+ // The `assignment` node's canonical config is the `assignments` map its
+ // descriptor and the Studio keyValue editor declare — `{ :
+ // }`, keys authored by the flow author — so the slot is spelled
+ // with the `*` wildcard: every value of that map is a `value` slot
+ // (#14149). Declared through the spec Zod channel
+ // (`AssignmentConfigSchema` in `builtin-node-config.zod.ts`, whose map
+ // value carries `.meta({ xExpression: 'value' })`, reaching the
+ // reconciliation ratchet through `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`):
+ // the descriptor's own `assignments` is `additionalProperties: true` and
+ // carries no marker, and objectui's inspector reads `xExpression` on
+ // string properties only, so the marker does not reach the Studio form.
+ //
+ // Deliberately NOT declared: the two legacy shapes the executor still reads
+ // (`logic-nodes.ts` — the bare `{ : }` config with no
+ // wrapper, and the `assignments: [{ variable, value }]` array). Neither is
+ // declared by the descriptor or offered for new authoring, and their
+ // values keep today's meaning (a `{token}` template or a literal — an
+ // envelope-shaped object there is a literal object, as it always was).
+ nodeType: 'assignment',
+ path: 'assignments.*',
+ role: 'value',
+ label: 'assignment value',
+ },
];
/** One resolved expression value found in a node's config. */
@@ -173,18 +241,53 @@ export interface ResolvedFlowNodeExpression {
readonly value: unknown;
}
+/**
+ * Is `value` shaped like an expression envelope — a plain object carrying a
+ * string `dialect`?
+ *
+ * The recognizer a `value` slot discriminates on (#14149): a plain string in
+ * such a slot is `{token}` flow interpolation, a plain object is a literal, and
+ * an object that names a `dialect` is an expression envelope
+ * (`ExpressionSchema`) — the one form the expression engine evaluates. It is
+ * deliberately looser than "a VALID envelope": `{ dialect: 'cel' }` with no
+ * `source` is envelope-shaped, so a malformed envelope reaches the validator
+ * and is refused there instead of being silently stored as a literal object.
+ * Same test the engine's `edge.condition` dispatch and the lint's page-envelope
+ * audit apply (`'dialect' in expression`), stated once so the ledger, the
+ * `assignment` config contract and the executor half all draw the line in the
+ * same place.
+ */
+export function isExpressionEnvelopeShaped(value: unknown): value is { dialect: string } {
+ return (
+ typeof value === 'object'
+ && value !== null
+ && !Array.isArray(value)
+ && typeof (value as { dialect?: unknown }).dialect === 'string'
+ );
+}
+
/**
* Resolve every expression slot the ledger declares for `nodeType` against a
- * concrete `config`, filling in array indices.
+ * concrete `config`, filling in array indices and wildcard keys.
*
* Pure path resolution — no validation, no I/O. Both the engine and the lint
* pass call this and then apply their own severity policy (the engine throws on
* a malformed predicate at `registerFlow`; the lint pass reports it as a located
* `objectstack validate` finding), so neither has to know the path shapes.
*
- * Absent, `null` and non-string values are skipped: "not authored" is not a
- * malformed expression, and a non-string in an expression slot is a *type*
- * violation for the schema pass to report, not something to hand to a parser.
+ * What counts as "an authored expression" depends on the role:
+ *
+ * - `predicate` / `flow-template`: the slot IS the expression, so a non-empty
+ * string is emitted. Absent, `null` and non-string values are skipped: "not
+ * authored" is not a malformed expression, and a non-string in an expression
+ * slot is a *type* violation for the schema pass to report, not something to
+ * hand to a parser.
+ * - `value`: the slot holds a VALUE that may be spelled as an expression, so
+ * only envelope-shaped objects ({@link isExpressionEnvelopeShaped}) are
+ * emitted. A string there is `{token}` interpolation — the `flow-template`
+ * dialect, not an expression to parse — and every other literal is data.
+ * Existing entries resolve byte-identically: no ledger path before #14149
+ * carries a `*` segment or the `value` role.
*/
export function resolveFlowNodeExpressions(
nodeType: string,
@@ -195,7 +298,11 @@ export function resolveFlowNodeExpressions(
for (const entry of FLOW_NODE_EXPRESSION_PATHS) {
if (entry.nodeType !== nodeType) continue;
walk(config as Record, entry.path.split('.'), '', (path, value) => {
- if (typeof value === 'string' && value.trim()) out.push({ entry, path, value });
+ if (entry.role === 'value') {
+ if (isExpressionEnvelopeShaped(value)) out.push({ entry, path, value });
+ } else if (typeof value === 'string' && value.trim()) {
+ out.push({ entry, path, value });
+ }
});
}
return out;
@@ -203,8 +310,8 @@ export function resolveFlowNodeExpressions(
/**
* Descend `segments` through `node`, expanding a `key[]` segment over every
- * element of that array, and hand each terminal value to `emit` with its
- * concrete path.
+ * element of that array and a `*` segment over every own key of that object,
+ * and hand each terminal value to `emit` with its concrete path.
*/
function walk(
node: unknown,
@@ -216,6 +323,19 @@ function walk(
const [head, ...rest] = segments;
if (head === undefined) return;
+ if (head === '*') {
+ // Every own key of a plain object (#14149). An array here is not "a map
+ // with authored keys" — its shape is `[]`'s, and a wildcard authored
+ // against the wrong container must not invent index paths.
+ if (Array.isArray(node)) return;
+ for (const [key, value] of Object.entries(node as Record)) {
+ const here = prefix ? `${prefix}.${key}` : key;
+ if (rest.length === 0) emit(here, value);
+ else walk(value, rest, here, emit);
+ }
+ return;
+ }
+
const isArraySegment = head.endsWith('[]');
const key = isArraySegment ? head.slice(0, -2) : head;
const value = (node as Record)[key];
diff --git a/packages/spec/src/automation/node-executor.zod.ts b/packages/spec/src/automation/node-executor.zod.ts
index 3d8812711d..21db4a9c7a 100644
--- a/packages/spec/src/automation/node-executor.zod.ts
+++ b/packages/spec/src/automation/node-executor.zod.ts
@@ -267,6 +267,14 @@ export const ActionDescriptorSchema = lazySchema(() => z.object({
* unvalidated the way `screen.fields[].visibleWhen` did for four months
* (#3528). Slots marked `xExpression: 'template'` are recorded but not
* checked: no validator implements the single-brace `{var}` dialect they use.
+ * 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.
+ * 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
* contract-carrying builtins** (#4277): those executors `parse()` their
* config against the Zod contracts in `io-node-config.zod.ts` /
diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts
index 590e097113..2a23878c76 100644
--- a/packages/spec/src/automation/schemaless-node-config.zod.ts
+++ b/packages/spec/src/automation/schemaless-node-config.zod.ts
@@ -103,6 +103,7 @@ 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';
/**
* What a rejected key on these contracts silently did before #4001 批 9 — and
@@ -449,24 +450,59 @@ export const SCHEMALESS_NODE_CONFIG_SCHEMAS = {
export type SchemalessNodeType = keyof typeof SCHEMALESS_NODE_CONFIG_SCHEMAS;
/**
- * {@link SCHEMALESS_NODE_CONFIG_SCHEMAS} as JSON Schema, memoized — the same
- * shape a descriptor's `configSchema` is, so a consumer can read both channels
- * with one walk instead of two notions of "a declared config property" (#4439).
+ * 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' })`,
+ * `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
+ * (`metadata-protocol`'s reference-site attribution) walk it for that reason,
+ * and `assignment` is not a member of that class.
+ */
+export const LEDGER_DECLARED_NODE_CONFIG_SCHEMAS = {
+ assignment: AssignmentConfigSchema,
+} as const satisfies Record;
+
+/** Node types whose expression slots reach the ledger through {@link LEDGER_DECLARED_NODE_CONFIG_SCHEMAS}. */
+export type LedgerDeclaredNodeType = keyof typeof LEDGER_DECLARED_NODE_CONFIG_SCHEMAS;
+
+/** Every node type the reconciliation JSON map below carries. */
+export type ReconciledNodeConfigType = SchemalessNodeType | LedgerDeclaredNodeType;
+
+/**
+ * {@link SCHEMALESS_NODE_CONFIG_SCHEMAS} and
+ * {@link LEDGER_DECLARED_NODE_CONFIG_SCHEMAS} as JSON Schema, memoized — the
+ * same shape a descriptor's `configSchema` is, so a consumer can read both
+ * channels with one walk instead of two notions of "a declared config
+ * property" (#4439).
*
* Derived in `input` mode like {@link getApprovalNodeConfigJsonSchema}, which
- * is what carries `.meta({ xExpression })` markers through verbatim.
+ * is what carries `.meta({ xExpression })` markers through verbatim — on a
+ * 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) — so nothing here reaches the
- * Studio property form. It exists so validation ledgers and reconciliation
- * ratchets can see these contracts at all.
+ * schemaless class (see this module's header), and `assignment`'s descriptor
+ * publishes its map 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.
*/
-let cachedSchemalessNodeConfigJsonSchemas: Readonly> | undefined;
-export function getSchemalessNodeConfigJsonSchemas(): Readonly> {
+let cachedSchemalessNodeConfigJsonSchemas: Readonly> | undefined;
+export function getSchemalessNodeConfigJsonSchemas(): Readonly> {
if (cachedSchemalessNodeConfigJsonSchemas === undefined) {
- const out = {} as Record;
- for (const [nodeType, schema] of Object.entries(SCHEMALESS_NODE_CONFIG_SCHEMAS)) {
- out[nodeType as SchemalessNodeType] = z.toJSONSchema(schema, {
+ const out = {} as Record;
+ const sources: ReadonlyArray = [
+ ...(Object.entries(SCHEMALESS_NODE_CONFIG_SCHEMAS) as ReadonlyArray),
+ ...(Object.entries(LEDGER_DECLARED_NODE_CONFIG_SCHEMAS) as ReadonlyArray),
+ ];
+ for (const [nodeType, schema] of sources) {
+ out[nodeType] = z.toJSONSchema(schema, {
target: 'draft-2020-12',
io: 'input',
unrepresentable: 'any',