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',