From 3ca412d1997d3571eddbf5c9c6b4a65b86f989c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 10:30:02 +0000 Subject: [PATCH 1/7] wip(spec): screen defaults, field defaultValue and recordId are value slots (#19939 S2) Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- .../src/automation/builtin-node-config.zod.ts | 128 ++++++++++++++-- .../automation/flow-node-config-refusals.ts | 68 ++++++--- .../automation/flow-node-expression-paths.ts | 65 ++++++-- .../automation/flow-value-slot-template.ts | 143 ++++++++++++++++-- .../automation/schemaless-node-config.zod.ts | 32 ++-- ...low-value-slot-template-dialect-refused.ts | 30 +++- 6 files changed, 387 insertions(+), 79 deletions(-) diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index cbe18e1969b..2d42ab86e82 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -74,8 +74,9 @@ * The `create_record` / `update_record` `fields` map carries the same value * contract since #19938 (`FlowValueSlotSchema`, the "value slots" section): * a field value is a CEL value envelope or a literal — a `{token}` template is - * refused there since #19939 — and the three maps are the expression ledger's - * `value`-role slots. + * refused there since #19939 — and those maps are the expression ledger's + * `value`-role slots, as are, since #19939, the three maps a node hands to a + * callee and a `screen`'s `defaults.*`, field `defaultValue` and `recordId`. * * Deliberately absent: * - `decision` / `script` / `subflow` / `wait` / `connector_action` — the @@ -257,7 +258,8 @@ const CRUD_BULK_INTENT_GUIDANCE = { * every way an envelope can be malformed AND for every `value`-role slot the * expression ledger declares (`assignment.assignments.*`, * `create_record.fields.*`, `update_record.fields.*`, and since #19939 - * `subflow.input.*`, `map.input.*`, `script.inputs.*`), so an author (or an + * `subflow.input.*`, `map.input.*`, `script.inputs.*` and a `screen`'s + * `defaults.*`, `fields[].defaultValue` and `recordId`), so an author (or an * agent reading the issue) learns the rule before the detail. * * Slot-neutral on purpose (#19938): the refusal's LOCATION names the slot @@ -413,7 +415,9 @@ function celValueSlotSchema(description: string) { * (`CreateRecordConfigSchema` / `UpdateRecordConfigSchema`, #19938), so do * the three maps a node hands to a callee since #19939 (`MapConfigSchema`'s * `input`; `SubflowConfigSchema`'s `input` and `ScriptConfigSchema`'s - * `inputs` in `schemaless-node-config.zod.ts`), and it is + * `inputs` in `schemaless-node-config.zod.ts`), and so do a `screen`'s + * `defaults` map and each field's `defaultValue` (`ScreenConfigSchema`; its + * `recordId` takes the same rule with a string literal), and it is * what a consumer judging ANY value-role slot applies (`AutomationEngine`'s * `valueEnvelopeRefusals`, the lint's `checkDeclaredValue`). The `assignment` * map's {@link AssignmentValueSchema} is the same rule under a @@ -725,8 +729,18 @@ export const ScreenFieldConfigSchema = lazySchema(() => strictObject({ value: z.unknown().describe('Stored value; it also addresses the option as text (its label translation key), so it must stay distinct from its siblings as text'), label: z.string().describe('Display label'), })).optional().describe('Choices for a select-style field; option values must stay distinct when read as text'), - /** Prefilled value; interpolates `{token}` templates at suspend time. */ - defaultValue: z.unknown().optional().describe('Prefilled value (interpolates {token} templates)'), + /** + * Prefilled value — a `value` slot (#19939): a CEL value envelope the + * executor evaluates when the screen is served, so the client receives the + * value it computes, never the envelope; or a literal, served as written. A + * `{token}` template is refused ({@link FlowValueSlotSchema}). + */ + defaultValue: FlowValueSlotSchema.optional() + .describe( + 'Prefilled value: a CEL value envelope `{ dialect: \'cel\', source }` evaluated when the screen is served ' + + '(the client receives the value, never the envelope), or a literal written as it is — a `{…}` template ' + + 'token is refused', + ), /** Input placeholder text. */ placeholder: z.string().optional().describe('Input placeholder text'), /** Bare-CEL predicate the client re-evaluates as values change (#3528). */ @@ -823,6 +837,65 @@ export const ScreenFieldConfigSchema = lazySchema(() => strictObject({ export type ScreenFieldConfig = z.input; +/** + * What an object-form screen's `recordId` that is neither a string nor a CEL + * value envelope is told (#19939). It names the slot's job before the remedy: + * the id names the record an `edit` form opens. + */ +const SCREEN_RECORD_ID_REFUSAL = + '`recordId` names the record an `edit` form opens: write the record\'s id as a string, or compute it with a CEL ' + + 'value envelope that evaluates to one, `{ dialect: \'cel\', source: \'record.id\' }`.'; + +/** + * `screen.recordId` — a `value` slot whose value NAMES a record (#19939, the C + * half of #11182 ruling D; ADR-0032 Decision 2, a computed value is whole-field + * CEL). Until #19939 it was `z.string()` read through the `{token}` + * interpolator, and an envelope there was refused; it gains the envelope arm, + * and the single-brace dialect leaves it: + * + * - a **string** is the id itself, served as written — a `{…}` token in it is + * refused by the one judge ({@link valueSlotTemplateRefusals}), naming the + * token's CEL spelling; + * - a **CEL value envelope** ({@link AssignmentExpressionValueSchema}) is + * evaluated by the executor when the screen is served, and must evaluate to + * a non-blank string — the executor refuses anything else at the screen, + * `null` included, rather than serving an `edit` form with no record to + * open (`service-automation`'s `screen-nodes.ts`); + * - anything else is refused ({@link SCREEN_RECORD_ID_REFUSAL}); a malformed + * envelope leads with {@link VALUE_ENVELOPE_REFUSAL}, as in every value slot. + * + * A union rather than {@link FlowValueSlotSchema}'s open `unknown`: the + * literal arm is narrower here (an id is a string, as it always was), and the + * union keeps that in the published JSON Schema and the inferred type. Built + * eagerly, like {@link celValueSlotSchema}: `.meta()` registers by identity, and + * the `xExpression: 'value'` marker is what the expression ledger's + * reconciliation ratchet reads. + */ +const ScreenRecordIdSlotSchema = z.union([z.string(), AssignmentExpressionValueSchema], { + error: (issue) => { + const input = (issue as { input?: unknown }).input; + if (!isExpressionEnvelopeShaped(input)) return SCREEN_RECORD_ID_REFUSAL; + const result = AssignmentExpressionValueSchema.safeParse(input); + const details = result.success + ? [] + : result.error.issues.map((i) => `${i.path.length > 0 ? `\`${i.path.map(String).join('.')}\`: ` : ''}${i.message}`); + return [VALUE_ENVELOPE_REFUSAL, ...details].join(' '); + }, +}) + .superRefine((value, ctx) => { + if (typeof value !== 'string') return; + for (const refusal of valueSlotTemplateRefusals(value)) { + ctx.addIssue({ code: 'custom', path: [...refusal.path], message: refusal.message }); + } + }) + .meta({ + description: + 'Object form only: the id of the record an edit form opens — a string, or a CEL value envelope ' + + '`{ dialect: \'cel\', source }` evaluated when the screen is served to the id (a non-blank string); a `{…}` ' + + 'template token is refused', + xExpression: 'value', + }); + /** * `screen` node config — what the executor reads. * @@ -837,8 +910,19 @@ export type ScreenFieldConfig = z.input; * holes over the flow's variables (`{{ record.name }}`), and a single-brace * `{token}` left from the 17.x dialect is refused by the `superRefine` below — * the one text-slot judge (`flow-text-slot-template.ts`) `registerFlow` and - * `objectstack validate` share. `recordId` and `defaults` keep the flow's - * single-brace `{token}` dialect: each hands its resolved value over, not text. + * `objectstack validate` share. + * + * `defaults.*`, each field's `defaultValue` and `recordId` are VALUE slots + * (#19939, the C half of #11182 ruling D; ADR-0032 Decision 2): each hands a + * value over, not text. Each takes a CEL value envelope, which the executor + * evaluates in the run's scope before the screen goes on the wire — the client + * receives the value it computes (a record stays a map, a list a list), never + * the envelope — or a literal, served as written. The single-brace `{token}` + * dialect they used to read is refused at all three, by the one value-slot + * judge (`flow-value-slot-template.ts`) the expression ledger's `value` entries + * route `registerFlow` and `objectstack validate` through, and by this + * contract at the executor's parse. `recordId`'s literal is narrower than the + * other two: a string, the id itself ({@link ScreenRecordIdSlotSchema}). */ export const ScreenConfigSchema = lazySchema(() => strictObject({ surface: 'this screen node config', @@ -895,12 +979,28 @@ export const ScreenConfigSchema = lazySchema(() => strictObject({ */ mode: z.enum(['create', 'edit']).default('create') .describe("Object form only: create (default) or edit; 'edit' needs a recordId to name its target"), - /** Object form only: id of the record `mode: 'edit'` edits. Interpolates `{token}`. */ - recordId: z.string().optional() - .describe("Object form only: id of the record to edit (required for mode: 'edit' to be useful)"), - /** Object form only: prefilled values; interpolates `{token}` templates. */ - defaults: z.record(z.string(), z.unknown()).optional() - .describe('Object form only: prefilled values'), + /** + * Object form only: id of the record `mode: 'edit'` edits — a `value` slot + * (#19939) whose literal is a string ({@link ScreenRecordIdSlotSchema}). + */ + recordId: ScreenRecordIdSlotSchema.optional() + .describe( + "Object form only: id of the record to edit (required for mode: 'edit' to be useful) — a string, or a CEL " + + 'value envelope `{ dialect: \'cel\', source }` evaluated when the screen is served to the id, a non-blank ' + + 'string (anything else, null included, fails the run at the screen); a `{…}` template token is refused', + ), + /** + * Object form only: prefilled values, keyed by the object's field names — a + * `value` slot per key (#19939): a CEL value envelope evaluated when the + * screen is served, or a literal; a `{token}` template is refused + * ({@link FlowValueSlotSchema}). + */ + defaults: z.record(z.string(), FlowValueSlotSchema).optional() + .describe( + "Object form only: prefilled values keyed by the object's field names — each a CEL value envelope " + + '`{ dialect: \'cel\', source }` evaluated when the screen is served (the client receives the value, never ' + + 'the envelope), or a literal written as it is; a `{…}` template token is refused', + ), }).superRefine((config, ctx) => { // [#22110] The two text slots render `{{ }}` holes; a single-brace token is // no placeholder any more and would show as literal text. diff --git a/packages/spec/src/automation/flow-node-config-refusals.ts b/packages/spec/src/automation/flow-node-config-refusals.ts index d0c8db84bf0..6df3be515e1 100644 --- a/packages/spec/src/automation/flow-node-config-refusals.ts +++ b/packages/spec/src/automation/flow-node-config-refusals.ts @@ -33,7 +33,7 @@ import { NON_BLANK_STRING } from '../shared/refinement-projection'; // so this module's import graph gains nothing. ⛔ Never restate either here. import { STORED_METADATA_BODY_PRESCRIPTION, isStoredMetadataBodyObject } from '../kernel/stored-metadata-body-objects'; import { FLOW_REGION_SLOTS_BY_TYPE } from './region-slots'; -import { FLOW_NODE_EXPRESSION_PATHS } from './flow-node-expression-paths'; +import { FLOW_NODE_EXPRESSION_PATHS, isExpressionEnvelopeShaped } from './flow-node-expression-paths'; import type { FlowNodeConfigRefusal, FlowSlotRefusalParams, NodeConfigValueKind } from './flow-node-expression-paths'; // The executor contracts. Read only inside `getBuiltinNodeConfigContracts`, // never at module load: `control-flow.zod.ts` sits in the flow-schema import @@ -229,17 +229,19 @@ function insideRegion(nodeType: string, path: ReadonlyArray): boole /** * Does an issue path descend INTO a ledger `value` slot (`fields.total.source` - * under `fields.*`)? Such a slot holds an authored VALUE — a literal, a - * `{token}` template, or an expression envelope — and a key missing inside it - * is a malformed value, judged at `registerFlow` and `objectstack validate` by - * the value-envelope pass with its own refusal, never a config key left out. + * under `fields.*`; since #19939 `fields[0].defaultValue.source` under a + * screen's `fields[].defaultValue`)? Such a slot holds an authored VALUE — a + * literal, a `{token}` template, or an expression envelope — and a key missing + * inside it is a malformed value, judged at `registerFlow` and `objectstack + * validate` by the value-envelope pass with its own refusal, never a config + * key left out. Matched segment by segment ({@link startsWithLedgerSlot}), so + * a slot under an array (`[]`) is recognised as well as one under a map. */ function insideValueSlot(nodeType: string, path: ReadonlyArray): boolean { return FLOW_NODE_EXPRESSION_PATHS.some((entry) => { if (entry.nodeType !== nodeType || entry.role !== 'value') return false; - const segments = entry.path.split('.'); - if (path.length <= segments.length) return false; - return segments.every((segment, i) => segment === '*' || segment === path[i]); + const segments = ledgerSlotSegments(entry.path); + return path.length > segments.length && startsWithLedgerSlot(segments, path); }); } @@ -308,22 +310,41 @@ function ledgerSlotSegments(path: string): string[] { return segments; } +/** Does `path` begin with the ledger slot `segments` spell — `*` any key, `[]` any index? */ +function startsWithLedgerSlot(segments: readonly string[], path: ReadonlyArray): boolean { + if (path.length < segments.length) return false; + return segments.every((segment, i) => + segment === '*' ? typeof path[i] === 'string' + : segment === '[]' ? typeof path[i] === 'number' + : segment === path[i]); +} + /** * [#21898] Does an issue path sit AT or INSIDE a `predicate` or `value` slot the - * expression ledger declares for this node type? Those slots have judges of - * their own: `predicateSlotRefusal` (whose non-strings the flow parse leaves to - * `registerFlow` and `objectstack validate` by ruling — `flow.zod.ts`), and the - * value-envelope pass. + * expression ledger declares for this node type, where that slot's own judge + * owns the finding? A `predicate` slot always has one: `predicateSlotRefusal` + * (whose non-strings the flow parse leaves to `registerFlow` and `objectstack + * validate` by ruling — `flow.zod.ts`). + * + * A `value` slot has two, and (#19939) only for the two forms they judge: the + * value-envelope pass owns a slot whose value is an envelope, and the + * value-slot judge owns a `{token}` in a literal — which the token carve-out in + * {@link builtinValueJudged} skips anyway. A LITERAL the contract refuses for + * its type has no other judge, so it stays this arm's: a screen's `recordId` + * takes a string literal and refuses `42` (`ScreenConfigSchema`), as it did + * when it was not a value slot. Every other value slot's literal arm refuses + * nothing but a token, so their verdicts do not move. */ -function atOrInsideJudgedLedgerSlot(nodeType: string, path: ReadonlyArray): boolean { +function atOrInsideJudgedLedgerSlot( + nodeType: string, + authored: Readonly>, + path: ReadonlyArray, +): boolean { return FLOW_NODE_EXPRESSION_PATHS.some((entry) => { if (entry.nodeType !== nodeType || !LEDGER_JUDGED_ROLES.has(entry.role)) return false; const segments = ledgerSlotSegments(entry.path); - if (path.length < segments.length) return false; - return segments.every((segment, i) => - segment === '*' ? typeof path[i] === 'string' - : segment === '[]' ? typeof path[i] === 'number' - : segment === path[i]); + if (!startsWithLedgerSlot(segments, path)) return false; + return entry.role !== 'value' || isExpressionEnvelopeShaped(authoredAt(authored, path.slice(0, segments.length))); }); } @@ -348,8 +369,8 @@ interface ContractIssue { * spelling before the doors that convert first; * - an ADR-0031 region slot, the slot itself included (`try: 5`): a region's * shape is `validateControlFlow`'s, and its nodes are the region walk's; - * - a `predicate` or `value` ledger slot, at or inside it - * ({@link atOrInsideJudgedLedgerSlot}); + * - a `predicate` ledger slot, at or inside it, and a `value` one whose value + * is an envelope ({@link atOrInsideJudgedLedgerSlot}); * - a run-resolved key ({@link RUN_RESOLVED_KEYS}); * - a value carrying a `{token}` anywhere inside it: it is never refused for * its pre-interpolation type, even where the executor parses the authored @@ -366,7 +387,7 @@ function builtinValueJudged( if (issue.code === 'unrecognized_keys') return false; if (isRetiredKeyIssue(issue)) return false; if ((FLOW_REGION_SLOTS_BY_TYPE.get(nodeType) ?? []).some((slot) => slot.key === issue.path[0])) return false; - if (atOrInsideJudgedLedgerSlot(nodeType, issue.path)) return false; + if (atOrInsideJudgedLedgerSlot(nodeType, authored, issue.path)) return false; if ((RUN_RESOLVED_KEYS[nodeType] ?? []).includes(issue.path[0] as string)) return false; if (carriesInterpolationToken(authoredAt(authored, issue.path))) return false; if (PARSED_AFTER_INTERPOLATION.has(nodeType) && issue.code === 'custom' && carriesInterpolationToken(authored)) return false; @@ -605,8 +626,9 @@ function unrecognizedKeysOf(issue: { readonly code: string }): readonly string[] * contract below uses. Kept only where {@link builtinValueJudged} holds — * key MEMBERSHIP is not judged by this bullet (an undeclared key is the * next one's, a tombstoned key nobody's here), nor a region slot, a - * `predicate` / `value` ledger slot, a run-resolved key, or any value - * carrying a `{token}`: never refused for its pre-interpolation type. + * `predicate` ledger slot, a `value` ledger slot holding an envelope, a + * run-resolved key, or any value carrying a `{token}`: never refused for + * its pre-interpolation type. * - (#21982) A key the contract does not declare, on every builtin * {@link builtinNodeConfigKeysJudged} judges (every one, `try_catch` * included since #22343) → `node-config-refused-by-contract`, anchored at diff --git a/packages/spec/src/automation/flow-node-expression-paths.ts b/packages/spec/src/automation/flow-node-expression-paths.ts index d4b12464162..da816e14f2a 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.ts @@ -143,6 +143,12 @@ export type FlowNodeExpressionRole = * `input`) — again declared and evaluated in the same change: the three * executors resolve the map through the one per-key resolver the CRUD * `fields` map uses (`service-automation`'s `resolveValueSlotMap`). + * + * And for a `screen`'s three positions that hand a value to the client — an + * object form's `defaults` map, a field's `defaultValue` and an object + * form's `recordId` — again declared and evaluated in the same change: the + * `screen` executor evaluates each before the screen goes on the wire, + * through the same resolver. */ | 'value'; @@ -202,8 +208,8 @@ export interface FlowNodeExpressionPath { * * Also deliberately absent: the config values that still INTERPOLATE * `{token}` templates — the positions the #19939 retirement has not reached - * yet (a `screen`'s `defaults`, a `notify` `recipients` entry, an `http` body, - * a CRUD `filter` value, and so on) — and the TEXT slots, which render `{{ }}` + * yet (a `notify` `recipients` entry, an `http` body, a CRUD `filter` value, + * and so on) — and the TEXT slots, which render `{{ }}` * holes since #22110 (a notify `title` / `message`, a screen `title` / * `description`, an `end` `message`; their one judge and list is * `flow-text-slot-template.ts`). Those are text-with-holes, @@ -221,20 +227,25 @@ export interface FlowNodeExpressionPath { * that form is resolved. Six maps are such slots: the `assignment` node's * `assignments` (#14149), the `create_record` / `update_record` `fields` * (#19938), and — since #19939 — the three maps a node hands to a callee as - * its inputs: `subflow.input`, `map.input` and `script.inputs`. Their strings + * its inputs: `subflow.input`, `map.input` and `script.inputs`. So are, since + * #19939, a `screen`'s three positions that hand a value to the client: the + * object form's `defaults` map, a field's `defaultValue` and the object form's + * `recordId` (a single value each, not a map: `fields[].defaultValue`, + * `recordId`). Their strings * are NOT the generic text-with-holes case above: since #19939 a `{token}` in * a value slot is refused (`flow-value-slot-template.ts`), and a token-free * string is a literal. Each is declared through the spec Zod channel — the - * map value is `FlowValueSlotSchema` (or `AssignmentValueSchema`, the same - * rule), whose `.meta({ xExpression: 'value' })` the ratchet reads. For + * value is `FlowValueSlotSchema` (or `AssignmentValueSchema`, the same rule; + * `recordId` takes it with a string literal), whose + * `.meta({ xExpression: 'value' })` the ratchet reads. For * `subflow` and `script`, which publish no descriptor `configSchema`, that is - * their contract in `SCHEMALESS_NODE_CONFIG_SCHEMAS`. For the other four it is + * their contract in `SCHEMALESS_NODE_CONFIG_SCHEMAS`. For the other five it is * `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` (`AssignmentConfigSchema`, - * `CreateRecordConfigSchema`, `UpdateRecordConfigSchema`, `MapConfigSchema`), - * because each of those nodes' descriptors 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. + * `CreateRecordConfigSchema`, `UpdateRecordConfigSchema`, `MapConfigSchema`, + * `ScreenConfigSchema`), because each of those nodes' descriptors declares the + * position with no marker — a map as `additionalProperties: true`; 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[] = [ { @@ -357,6 +368,38 @@ export const FLOW_NODE_EXPRESSION_PATHS: readonly FlowNodeExpressionPath[] = [ role: 'value', label: 'script input value', }, + { + // [#19939] A `screen`'s three positions that hand a VALUE to the client + // (#11182 ruling D; ADR-0032 Decision 2). Each is evaluated by the + // executor in the run's scope BEFORE the screen goes on the wire, so the + // client receives the value, never the envelope. Declared through the + // spec Zod channel (`ScreenConfigSchema`, carried to the ratchet by + // `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`): the descriptor declares + // `defaults` as `additionalProperties: true` and the other two with no + // marker, the shape its Studio form renders. + // + // An object form's prefill, keyed by the object's field names. + nodeType: 'screen', + path: 'defaults.*', + role: 'value', + label: 'screen form default', + }, + { + // A flat screen field's prefill. + nodeType: 'screen', + path: 'fields[].defaultValue', + role: 'value', + label: 'screen field default value', + }, + { + // The record an object form's `edit` mode opens. Its literal is narrower + // than the other value slots': a string, the id itself, and an envelope + // must evaluate to one (`ScreenConfigSchema.recordId`). + nodeType: 'screen', + path: 'recordId', + role: 'value', + label: 'screen record id', + }, ]; /** One resolved expression value found in a node's config. */ diff --git a/packages/spec/src/automation/flow-value-slot-template.ts b/packages/spec/src/automation/flow-value-slot-template.ts index 04c2260f5ec..4e10b6c4b9e 100644 --- a/packages/spec/src/automation/flow-value-slot-template.ts +++ b/packages/spec/src/automation/flow-value-slot-template.ts @@ -8,9 +8,11 @@ * * A value slot is a config position whose authored value BECOMES a value: the * expression ledger's `value` role (`assignment.assignments.*`, - * `create_record.fields.*`, `update_record.fields.*`, and the three maps a - * node hands to a callee — `subflow.input.*`, `map.input.*`, - * `script.inputs.*` — `FLOW_NODE_EXPRESSION_PATHS`), plus the two legacy + * `create_record.fields.*`, `update_record.fields.*`, the three maps a node + * hands to a callee — `subflow.input.*`, `map.input.*`, `script.inputs.*` — + * and a `screen`'s three positions that hand a value to the client — + * `defaults.*`, `fields[].defaultValue`, `recordId` — + * `FLOW_NODE_EXPRESSION_PATHS`), plus the two legacy * `assignment` shapes the executor still reads (the * `assignments: [{ variable, value }]` array and the bare * `{ : }` config). Until this retirement every string @@ -116,6 +118,32 @@ * executor's contract parse, which judges a value with no position, names the * remedy without it. * + * ## A screen's prefill and record id — evaluated before the screen is served + * + * Three value slots hand their value to the CLIENT: an object form's + * `defaults` (its fields' prefill), a flat field's `defaultValue`, and an + * object form's `recordId` (the record `edit` mode opens). The `screen` + * executor evaluates each envelope before the screen goes on the wire, so the + * client receives the value, never the envelope. Where a whole token resolved + * to nothing, the template served NOTHING there, and each position answers + * the guarded form's `null` its own way, so the refusal at a top-level value + * adds the screen sentence (`SCREEN_VALUE_SLOTS`, keyed by the ledger entry): + * + * - `defaults.`: the object form seeded a field the prefill left out + * from the object's declared `defaultValue`, and takes a `null` prefill as + * a value — it opens the field blank over that default. So: write the + * default in the guard's `null` branch to keep it. + * - `fields[i].defaultValue`: the screen now carries `null` where it carried + * nothing, which a client seeds the field with and submits back. + * - `recordId`: it must evaluate to the record's id, a non-blank string; the + * executor refuses anything else at the screen, `null` included, where the + * template served the screen with no record id (an `edit` form then opens + * empty and cannot save). So: route around a screen whose record may be + * absent rather than guarding the id. + * + * A `$User` path that never resolved served nothing in every run, and the + * sentence says leaving the value out keeps exactly that. + * * ## The token grammar is the interpolator's * * Which `{…}` the interpolator substitutes, and how it dispatches a token, is @@ -491,9 +519,9 @@ const CALLEE_INPUT_SLOTS: Readonly> = { */ function calleeSentence(callee: Callee, mapKey: string, key: string, token: Token): string | undefined { const flow = callee === 'child-flow' ? 'the child flow' : callee === 'item-flow' ? 'each item\'s child flow' : undefined; - const neverResolved = token.kind === 'user' && !isRunUserIdToken(token.inner); - const handsNull = token.kind === 'path' || token.kind === 'expression' || (token.kind === 'user' && !neverResolved); - if (!handsNull && !neverResolved) return undefined; + const absent = absentCaseOf(token); + if (absent === undefined) return undefined; + const neverResolved = absent === 'never-resolved'; if (flow === undefined) { return neverResolved ? `\`${mapKey}.${key}\` is the function's \`input.${key}\`, which the template handed \`undefined\` in every run: ` @@ -510,6 +538,88 @@ function calleeSentence(callee: Callee, mapKey: string, key: string, token: Toke + `\`${mapKey}\` only where the value is never meant to be supplied.`; } +/** + * What a refused whole token's remedy changes where the value was ABSENT — + * the case a callee's input and a screen's value slots each answer their own + * way — or `undefined` where the remedy changes nothing there: a date macro + * (never absent), braces that are neither a path nor an expression. + * + * - `'null'`: the template wrote nothing for a token that resolved to + * nothing, and the remedy's guarded form writes `null` — a path, an + * expression, the run user's id; + * - `'never-resolved'`: a `$User` path other than the id, which wrote + * nothing in every run. + */ +function absentCaseOf(token: Token): 'null' | 'never-resolved' | undefined { + if (token.kind === 'user') return isRunUserIdToken(token.inner) ? 'null' : 'never-resolved'; + return token.kind === 'path' || token.kind === 'expression' ? 'null' : undefined; +} + +/** + * Which of a `screen`'s value slots a ledger entry is — keyed by the entry + * (` `), like {@link CALLEE_INPUT_SLOTS}, so the sentence + * follows the ledger's declaration (see the module docblock, "A screen's + * prefill and record id"). + */ +type ScreenSlot = 'form-default' | 'field-default' | 'record-id'; + +const SCREEN_VALUE_SLOTS: Readonly> = { + 'screen defaults.*': 'form-default', + 'screen fields[].defaultValue': 'field-default', + 'screen recordId': 'record-id', +}; + +/** + * The screen sentence for a refused TOP-LEVEL value of one of a `screen`'s + * value slots, located at `at` (`defaults.account`, `fields[0].defaultValue`, + * `recordId`) — what the client is served where the template served nothing — + * or `undefined` where the remedy changes nothing there (text with holes is a + * string; see {@link absentCaseOf}). + * + * Measured through the engine (`screen-value-slots.test.ts` in + * `service-automation`): an unresolved whole token served nothing (the key + * left off the wire); a guarded envelope's `null` is served as `null`; a + * `recordId` that evaluates to anything but a non-blank string fails the run + * at the screen. And at objectui's pinned console: an object form seeds a + * field a prefill leaves out from the object's declared `defaultValue` and + * keeps a `null` prefill as a value; a flat field's `defaultValue` seeds the + * field and is submitted back; an `edit` form with no `recordId` opens with + * nothing read and refuses to save. + */ +function screenSentence(slot: ScreenSlot, at: string, token: Token): string | undefined { + const absent = absentCaseOf(token); + if (absent === undefined) return undefined; + if (slot === 'record-id') { + return absent === 'never-resolved' + ? '`recordId` names the record an `edit` form opens, and the template served this screen with no record id in ' + + 'every run, so the form opened with no record and could not save: compute the id of the record it should ' + + 'open.' + : '`recordId` names the record an `edit` form opens, so the envelope must evaluate to that record\'s id, a ' + + 'non-blank string: anything else — the guarded form\'s `null` included — fails the run at this screen, ' + + 'where the template served it with no record id and the `edit` form opened with no record and could not ' + + 'save. Where the record may be absent, route around the screen with a `decision` on the value rather ' + + 'than guarding the id.'; + } + if (slot === 'form-default') { + const field = at.slice('defaults.'.length); + return absent === 'never-resolved' + ? `\`${at}\` prefills the object form's \`${field}\` field, which the template prefilled with nothing in every ` + + 'run, so the form seeded it from the object\'s declared `defaultValue`: leaving the key out of `defaults` ' + + 'keeps exactly that.' + : `\`${at}\` prefills the object form's \`${field}\` field. Where the template prefilled nothing, the form ` + + 'seeded that field from the object\'s declared `defaultValue`; the guarded form prefills `null`, which the ' + + 'form keeps as a value and opens the field blank over that default. To keep the default, write it in the ' + + 'guard\'s `null` branch; leave the key out of `defaults` only where the field is never meant to be ' + + 'prefilled.'; + } + return absent === 'never-resolved' + ? `\`${at}\` prefills this screen field, which the template prefilled with nothing in every run: leaving ` + + '`defaultValue` out keeps exactly that.' + : `\`${at}\` prefills this screen field. Where the template prefilled nothing, the guarded form prefills ` + + '`null`, a value the field is seeded with and that is submitted back where the field held none. To prefill ' + + 'a value of your own when the value is absent, write it in the guard\'s `null` branch.'; +} + function joinPath(prefix: string, inner: readonly (string | number)[]): string { let out = prefix; for (const segment of inner) out += typeof segment === 'number' ? `[${segment}]` : (out ? `.${segment}` : segment); @@ -535,7 +645,9 @@ function joinPath(prefix: string, inner: readonly (string | number)[]): string { * At a top-level value of a map the node hands to a callee (`subflow.input`, * `map.input`, `script.inputs`), a remedy that names a guard or a run-user * read ends with the callee sentence: what the callee is handed instead of - * nothing (see the module docblock). + * nothing (see the module docblock). At a top-level value of a `screen`'s value + * slots (`defaults.*`, `fields[].defaultValue`, `recordId`) it ends with the + * screen sentence: what the client is served instead of nothing. */ export function flowNodeValueTemplateRefusals(nodeType: string, config: unknown): FlowNodeValueTemplateRefusal[] { const out: FlowNodeValueTemplateRefusal[] = []; @@ -553,18 +665,25 @@ export function flowNodeValueTemplateRefusals(nodeType: string, config: unknown) } }; for (const found of resolveFlowNodeValueSlots(nodeType, config)) { - const receiver = CALLEE_INPUT_SLOTS[`${found.entry.nodeType} ${found.entry.path}`]; - // The map's own key (`input.`) — the entry's path ends in its `*`. + const entryKey = `${found.entry.nodeType} ${found.entry.path}`; + const receiver = CALLEE_INPUT_SLOTS[entryKey]; + const screenSlot = SCREEN_VALUE_SLOTS[entryKey]; + // The map's own key (`input.`) — a callee entry's path ends in its `*`. const mapKey = found.entry.path.slice(0, -2); const key = found.path.slice(found.entry.path.length - 1); - const callee = receiver === undefined + const sentenceFor = receiver !== undefined + ? (whole: Token): string | undefined => calleeSentence(receiver, mapKey, key, whole) + : screenSlot !== undefined + ? (whole: Token): string | undefined => screenSentence(screenSlot, found.path, whole) + : undefined; + const sentence = sentenceFor === undefined ? undefined : (refusal: ValueSlotTemplateRefusal): string | undefined => { if (refusal.path.length > 0) return undefined; const whole = wholeTokenOf(refusal.source, tokensOf(refusal.source)); - return whole === undefined ? undefined : calleeSentence(receiver, mapKey, key, whole); + return whole === undefined ? undefined : sentenceFor(whole); }; - judge(found.path, found.entry.label, found.value, false, callee); + judge(found.path, found.entry.label, found.value, false, sentence); } if (nodeType === 'assignment' && config !== null && typeof config === 'object' && !Array.isArray(config)) { const raw = (config as Record).assignments; diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts index b46dd2a633c..a4c305b6705 100644 --- a/packages/spec/src/automation/schemaless-node-config.zod.ts +++ b/packages/spec/src/automation/schemaless-node-config.zod.ts @@ -119,6 +119,7 @@ import { CreateRecordConfigSchema, FlowValueSlotSchema, MapConfigSchema, + ScreenConfigSchema, UpdateRecordConfigSchema, } from './builtin-node-config.zod'; @@ -649,31 +650,36 @@ export type SchemalessNodeType = keyof typeof SCHEMALESS_NODE_CONFIG_SCHEMAS; /** * Node types that DO publish a descriptor `configSchema` and still declare an * expression slot through a spec Zod, because the descriptor cannot carry the - * marker (#14149; the CRUD pair since #19938; `map` since #19939). + * marker (#14149; the CRUD pair since #19938; `map` and `screen` since #19939). * * Each of these descriptors declares its map as `additionalProperties: true` * — `assignment`'s `assignments`, `create_record` / `update_record`'s - * `fields`, `map`'s `input` — and that openness IS the descriptor's contract, - * pinned by the form↔Zod ledger, so there is no descriptor property to mark. - * The value contract lives on the spec Zod's map value - * (`.meta({ xExpression: 'value' })` — `AssignmentValueSchema` and + * `fields`, `map`'s `input`, `screen`'s `defaults` — and that openness IS the + * descriptor's contract, pinned by the form↔Zod ledger, so there is no + * descriptor property to mark. The value contract lives on the spec Zod's map + * value (`.meta({ xExpression: 'value' })` — `AssignmentValueSchema` and * `FlowValueSlotSchema`, `builtin-node-config.zod.ts`), and the expression * ledger's reconciliation ratchet reads it from the JSON projection below, * walking the map's `additionalProperties` as the `*` segment the ledger paths - * `assignments.*`, `fields.*` and `input.*` spell. `map`'s other expression - * slot, `collection`, keeps arriving through its descriptor (`xExpression: - * 'template'`): `MapConfigSchema` carries no marker there, so the two channels - * never declare one slot twice. 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 none of these four is a member of - * that class. + * `assignments.*`, `fields.*`, `input.*` and `defaults.*` spell. `screen`'s + * other two value slots, a field's `defaultValue` and `recordId`, are single + * values its descriptor offers as unmarked inputs (the Studio form renders + * them as it always did), so their markers ride `ScreenConfigSchema` too. + * `map`'s other expression slot, `collection`, keeps arriving through its + * descriptor (`xExpression: 'template'`), and so does `screen`'s + * `fields[].visibleWhen` (`xExpression: 'expression'`): the spec Zod carries no + * marker there, so the two channels never declare one slot twice. 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 none of these five + * is a member of that class. */ export const LEDGER_DECLARED_NODE_CONFIG_SCHEMAS = { assignment: AssignmentConfigSchema, create_record: CreateRecordConfigSchema, update_record: UpdateRecordConfigSchema, map: MapConfigSchema, + screen: ScreenConfigSchema, } as const satisfies Record; /** Node types whose expression slots reach the ledger through {@link LEDGER_DECLARED_NODE_CONFIG_SCHEMAS}. */ diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts index 6fc8fab048b..07e7bea8eb7 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts @@ -9,7 +9,10 @@ import type { SemanticMigration } from '../../types.js'; // the run's user or null. So are the date macros, with their CEL string form // (isoDate / isoDatetime): no single-brace spelling is kept. The surface widens // to the maps a node hands to a callee (subflow and map input, script inputs), -// where a guarded form's null is supplied and wins over a child's default. +// where a guarded form's null is supplied and wins over a child's default, and +// to a screen's three positions that hand a value to the client (an object +// form's defaults, a field's defaultValue, an object form's recordId), each +// evaluated before the screen goes on the wire. export const entry: SemanticMigration = { id: 'flow-value-slot-template-dialect-refused', // No backticks in `surface` — build-upgrade-guide renders it inside a code @@ -18,7 +21,8 @@ export const entry: SemanticMigration = { 'flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the ' + 'legacy bare config), of create_record and update_record nodes (the fields map), and of the nodes that hand ' + 'a map to a callee: a subflow node and a map node (the input map, evaluated per item on a map node) and a ' - + 'script node (the inputs map) — a string value, or a ' + + 'script node (the inputs map), and of a screen node (an object form\'s defaults map and its recordId, and ' + + 'each field\'s defaultValue) — a string value, or a ' + 'string anywhere inside an array or object value, carrying a single-brace template token, the run-user ' + 'paths beginning $User. and the date macros NOW() and TODAY() with an optional day offset included', replacement: @@ -43,7 +47,14 @@ export const entry: SemanticMigration = { + 'child flow\'s input variable of that name: a guarded form\'s null is a supplied value that wins over the ' + 'child variable\'s defaultValue, so to keep the default for an absent value write the default in the guard\'s ' + 'null branch (has(vars.x) ? vars.x : \'the default\'), and leave the key out only where the value is never ' - + 'meant to be supplied; a script function is handed null where the template handed undefined', + + 'meant to be supplied; a script function is handed null where the template handed undefined. On a screen, ' + + 'each envelope is evaluated in the run\'s scope before the screen goes on the wire, so the client receives ' + + 'the value — a record as a map, a list as a list — never the envelope. A guarded form\'s null is served as ' + + 'null where the template served nothing: in an object form\'s defaults the form keeps it as a value and ' + + 'opens the field blank over the object field\'s declared defaultValue, so write that default in the guard\'s ' + + 'null branch to keep it, and a field\'s defaultValue seeds the field with null. recordId stays a string when ' + + 'written as one, and an envelope there must evaluate to the record\'s id, a non-blank string: anything else, ' + + 'null included, fails the run at the screen, so route around a screen whose record may be absent', reason: 'The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no ' + 'conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index ' @@ -61,7 +72,10 @@ export const entry: SemanticMigration = { + 'addDays truncates the offset itself and daysFromNow and daysAgo refuse a fraction at build. Where the map ' + 'is handed to a callee, a whole token that resolved to nothing handed the callee nothing, and the child flow ' + 'then seeded the input variable from its defaultValue; under CEL that value either fails the run or, guarded, ' - + 'is null, which the child takes as supplied, so the default no longer applies unless the guard writes it. A ' + + 'is null, which the child takes as supplied, so the default no longer applies unless the guard writes it. ' + + 'On a screen, a whole token that resolved to nothing served nothing: an object form then seeded the field ' + + 'from the object field\'s defaultValue, and a recordId that resolved to nothing served an edit form with no ' + + 'record, which opened with nothing read and could not save. A ' + 'flow carrying ' + 'a refused value is refused at registration, by objectstack validate and by the executor; a stored flow ' + 'carrying one is skipped at boot with a warn naming it.', @@ -75,6 +89,10 @@ export const entry: SemanticMigration = { + 'a system write can make): there, guard current_user.id, or skip the node with a start condition or a ' + 'decision on current_user != null where an update_record must leave the stored value alone. For a date ' + 'macro with a variable offset, check the variable is always set to a number where the flow runs; write a ' - + 'fractional offset as the whole number of days meant. Re-run the flow paths that write those fields and ' - + 'compare the stored values with the ones the template wrote.', + + 'fractional offset as the whole number of days meant. In a subflow or map input where the child\'s ' + + 'defaultValue should apply to an absent value, write that default in the guard\'s null branch, or leave the ' + + 'key out; in an object form\'s defaults, likewise write the object field\'s default in the guard. For a ' + + 'screen\'s recordId, find the paths where the record can be absent and route them around the screen. Re-run ' + + 'the flow paths that write those fields and serve those screens, and compare the stored and served values ' + + 'with the ones the template wrote.', }; From a116b9ac88123b7e3702bb3d3e1980ae1ce2c141 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 10:33:27 +0000 Subject: [PATCH 2/7] wip(spec): pin the screen value slots at the contract, the ledger, the judge and the doors (#19939 S2) Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- .../automation/builtin-node-config.test.ts | 105 +++++++++++++++- .../flow-builtin-node-config-values.test.ts | 14 +++ .../flow-node-expression-paths.test.ts | 65 +++++++++- .../flow-text-slot-template.test.ts | 20 ++- .../flow-value-slot-template.test.ts | 114 +++++++++++++++++- 5 files changed, 305 insertions(+), 13 deletions(-) diff --git a/packages/spec/src/automation/builtin-node-config.test.ts b/packages/spec/src/automation/builtin-node-config.test.ts index 3e4bd66921c..6b0e046a23a 100644 --- a/packages/spec/src/automation/builtin-node-config.test.ts +++ b/packages/spec/src/automation/builtin-node-config.test.ts @@ -212,7 +212,8 @@ describe('ScreenConfigSchema / ScreenFieldConfigSchema — an unknown key is ref fields: [{ name: 'amount', label: 'Amount', type: 'number', required: true, defaultValue: 0, placeholder: '0.00', visibleWhen: "type == 'x'" }], }).success).toBe(true); expect(ScreenConfigSchema.safeParse({ - objectName: 'showcase_task', mode: 'edit', recordId: '{record.id}', idVariable: 'savedId', defaults: { status: 'open' }, + // [#19939] `recordId` is a value slot: the id is a string, or a CEL envelope. + objectName: 'showcase_task', mode: 'edit', recordId: { dialect: 'cel', source: 'record.id' }, idVariable: 'savedId', defaults: { status: 'open' }, }).success).toBe(true); }); @@ -580,8 +581,9 @@ describe('assignment value contract — a CEL envelope beside literals; the `{to // `SCHEMALESS_NODE_CONFIG_SCHEMAS`, whose meaning ("publishes no // descriptor") other readers depend on. // #19938 — the CRUD write map joined through the same channel; #19939 — - // the `map` node's per-item `input` map. - expect(Object.keys(LEDGER_DECLARED_NODE_CONFIG_SCHEMAS)).toEqual(['assignment', 'create_record', 'update_record', 'map']); + // the `map` node's per-item `input` map, then the `screen`'s prefill and + // record id. + expect(Object.keys(LEDGER_DECLARED_NODE_CONFIG_SCHEMAS)).toEqual(['assignment', 'create_record', 'update_record', 'map', 'screen']); expect(Object.keys(SCHEMALESS_NODE_CONFIG_SCHEMAS).sort()).toEqual(['decision', 'script', 'subflow']); const projected = getSchemalessNodeConfigJsonSchemas().assignment as { properties?: Record }> }; @@ -911,3 +913,100 @@ describe('CRUD `fields` value contract — the CEL value envelope beside literal expect(projected.properties?.filter?.additionalProperties?.xExpression).toBeUndefined(); }); }); + +/** + * [#19939, stage S2] A `screen`'s three positions that hand a value to the + * client are VALUE slots: an object form's `defaults.*`, a field's + * `defaultValue` and an object form's `recordId`. Each takes a CEL value + * envelope (evaluated by the executor before the screen is served) or a + * literal; the `{token}` dialect is refused at all three. `recordId`'s literal + * stays what it was — a string — and it gains the envelope arm. + */ +describe('screen value slots — `defaults.*`, a field\'s `defaultValue`, `recordId`', () => { + const REC = { dialect: 'cel', source: 'rec' }; + const ROWS = { dialect: 'cel', source: 'rows' }; + const ID = { dialect: 'cel', source: 'record.id' }; + const issuesOf = (config: unknown) => { + const result = ScreenConfigSchema.safeParse(config); + return result.success ? [] : result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); + }; + + it('accepts an envelope at each position, stored verbatim — the executor evaluates it', () => { + const config = { + objectName: 'account', mode: 'edit', recordId: ID, + defaults: { parent: REC, contacts: ROWS }, + fields: [{ name: 'note', defaultValue: { dialect: 'cel', source: "'Hi ' + rec.name" } }], + }; + const result = ScreenConfigSchema.safeParse(config); + expect(result.success).toBe(true); + expect(result.data).toMatchObject(config); + }); + + it('PRESERVATION: every literal still parses, unchanged — a string id, any prefill value', () => { + const literals = { + objectName: 'account', mode: 'edit', recordId: 'acc_1', + defaults: { stage: 'qualification', amount: 0, active: false, nothing: null, tags: ['a', { dialect: 'cel' }], meta: { source: 'x' } }, + fields: [ + { name: 'priority', defaultValue: 'normal' }, { name: 'n', defaultValue: 0 }, + { name: 'b', defaultValue: false }, { name: 'list', defaultValue: ['x'] }, { name: 'none' }, + ], + }; + const result = ScreenConfigSchema.safeParse(literals); + expect(result.success).toBe(true); + expect(result.data).toMatchObject(literals); + }); + + it.each([ + ['recordId', { objectName: 'account', mode: 'edit', recordId: '{record.id}' }, 'recordId', "{ dialect: 'cel', source: 'record.id' }"], + ['defaults', { objectName: 'account', defaults: { name: '{lead.company}' } }, 'defaults.name', "{ dialect: 'cel', source: 'lead.company' }"], + ['defaults, nested', { objectName: 'account', defaults: { tags: ['{lead.tag}'] } }, 'defaults.tags.0', "source: '[lead.tag]'"], + ['defaultValue', { fields: [{ name: 'email', defaultValue: '{lead.email}' }] }, 'fields.0.defaultValue', "{ dialect: 'cel', source: 'lead.email' }"], + ] as const)('refuses a `{token}` in %s, at its path, naming the CEL spelling', (_what, config, path, spelling) => { + const issues = issuesOf(config); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', path]]); + expect(issues[0]!.message.startsWith(VALUE_SLOT_TEMPLATE_REFUSAL)).toBe(true); + expect(issues[0]!.message).toContain(spelling); + }); + + it.each([ + ['a number', 42], ['null', null], ['a list', ['acc_1']], ['a plain object', { id: 'acc_1' }], ['a boolean', true], + ] as const)('recordId: refuses %s — an id is a string, or an envelope that evaluates to one', (_what, recordId) => { + const issues = issuesOf({ objectName: 'account', mode: 'edit', recordId }); + expect(issues.map((i) => i.path)).toEqual(['recordId']); + expect(issues[0]!.message).toBe( + '`recordId` names the record an `edit` form opens: write the record\'s id as a string, or compute it with a CEL ' + + 'value envelope that evaluates to one, `{ dialect: \'cel\', source: \'record.id\' }`.', + ); + }); + + it.each([ + ['recordId', (e: unknown) => ({ objectName: 'a', mode: 'edit', recordId: e }), 'recordId'], + ['defaults', (e: unknown) => ({ objectName: 'a', defaults: { k: e } }), 'defaults.k'], + ['defaultValue', (e: unknown) => ({ fields: [{ name: 'k', defaultValue: e }] }), 'fields.0.defaultValue'], + ] as const)('%s: a malformed envelope is refused, led by the slot-neutral envelope sentence', (_what, configOf, at) => { + for (const envelope of [{ dialect: 'cel' }, { dialect: 'template', source: 'Hi {{ name }}' }]) { + const issues = issuesOf(configOf(envelope)); + expect(issues.length, JSON.stringify(envelope)).toBeGreaterThan(0); + for (const issue of issues) { + expect(issue.path.startsWith(at), JSON.stringify(envelope)).toBe(true); + expect(issue.message.startsWith(VALUE_ENVELOPE_REFUSAL), JSON.stringify(envelope)).toBe(true); + } + } + }); + + it('declares the three slots to the expression ledger — `xExpression: \'value\'` on the spec Zod, never on `visibleWhen`', () => { + type Node = { properties?: Record; items?: Node; additionalProperties?: Node; anyOf?: Node[]; type?: string; xExpression?: string }; + for (const json of [ + z.toJSONSchema(ScreenConfigSchema, { target: 'draft-2020-12', io: 'input', unrepresentable: 'any' }) as Node, + getSchemalessNodeConfigJsonSchemas().screen as Node, + ]) { + expect(json.properties?.defaults?.additionalProperties?.xExpression).toBe('value'); + expect(json.properties?.fields?.items?.properties?.defaultValue?.xExpression).toBe('value'); + expect(json.properties?.recordId?.xExpression).toBe('value'); + // The id's literal stays a string in the published shape, beside the envelope arm. + expect(json.properties?.recordId?.anyOf?.map((arm) => arm.type)).toEqual(['string', 'object']); + // `visibleWhen` keeps arriving through the descriptor channel alone. + expect(json.properties?.fields?.items?.properties?.visibleWhen?.xExpression).toBeUndefined(); + } + }); +}); diff --git a/packages/spec/src/automation/flow-builtin-node-config-values.test.ts b/packages/spec/src/automation/flow-builtin-node-config-values.test.ts index cc0953d22a8..3afa557b886 100644 --- a/packages/spec/src/automation/flow-builtin-node-config-values.test.ts +++ b/packages/spec/src/automation/flow-builtin-node-config-values.test.ts @@ -149,6 +149,9 @@ describe('one refusal per judged builtin, at each typed key', () => { ['screen', { waitForInput: 'yes' }, 'waitForInput'], ['screen', { mode: 'view' }, 'mode'], ['screen', { fields: [{ name: 'qty', required: 'yes' }] }, 'fields[0].required'], + // [#19939] `recordId` is a value slot whose literal is a string: a literal + // the contract refuses for its type has no other judge, so it stays here. + ['screen', { recordId: 42 }, 'recordId'], ['script', { function: '' }, 'function'], ['script', { inputs: 'leadId' }, 'inputs'], ['subflow', { flowName: '' }, 'flowName'], @@ -250,6 +253,17 @@ describe('what stays accepted (lit controls)', () => { expect(flowNodeConfigRefusals('screen', { fields: [{ name: 'qty', visibleWhen: { dialect: 'cel', source: 'x' } }] })).toEqual([]); // A `value` ledger slot: the value-envelope pass judges it. expect(flowNodeConfigRefusals('create_record', { objectName: 'task', fields: { title: { dialect: 'cel', source: 1 } } })).toEqual([]); + // [#19939] …at a screen's value slots too, a single value and one under the + // `fields[]` repeater included — never re-judged, nor read as a key left out. + for (const config of [ + { objectName: 'a', mode: 'edit', recordId: { dialect: 'cel' } }, + { objectName: 'a', defaults: { k: { dialect: 'cel' } } }, + { fields: [{ name: 'qty', defaultValue: { dialect: 'cel' } }] }, + { fields: [{ name: 'qty', defaultValue: { dialect: 'template', source: 'x' } }] }, + ]) { + expect(getBuiltinNodeConfigContracts().get('screen')!.schema.safeParse(config).success, JSON.stringify(config)).toBe(false); + expect(flowNodeConfigRefusals('screen', config), JSON.stringify(config)).toEqual([]); + } // A region slot: `validateControlFlow` judges a region's shape at registration. expect(flowNodeConfigRefusals('try_catch', { try: 5 })).toEqual([]); expect(flowNodeConfigRefusals('parallel', { branches: [{ name: 'a', ...REGION }] })).toEqual([]); diff --git a/packages/spec/src/automation/flow-node-expression-paths.test.ts b/packages/spec/src/automation/flow-node-expression-paths.test.ts index c82d8bf9c76..d1b4cfb1780 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.test.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.test.ts @@ -224,6 +224,57 @@ describe('FLOW_NODE_EXPRESSION_PATHS — the callee input maps (#19939)', () => }); }); +/** + * #19939 (stage S2) — a `screen`'s three positions that hand a value to the + * client are `value` slots: an object form's `defaults.*`, a field's + * `defaultValue` (under the `fields[]` repeater) and an object form's + * `recordId` (a single value). `fields[].visibleWhen` keeps its `predicate` + * entry beside them. + */ +describe('FLOW_NODE_EXPRESSION_PATHS — a screen\'s value slots (#19939)', () => { + const ENVELOPE = { dialect: 'cel', source: 'rec' }; + + it('declares `defaults.*`, `fields[].defaultValue` and `recordId` as `value` slots, after `visibleWhen`', () => { + expect(FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.nodeType === 'screen').map((e) => [e.path, e.role, e.label])).toEqual([ + ['fields[].visibleWhen', 'predicate', 'screen field visibleWhen'], + ['defaults.*', 'value', 'screen form default'], + ['fields[].defaultValue', 'value', 'screen field default value'], + ['recordId', 'value', 'screen record id'], + ]); + }); + + it('resolves the envelope values, and only them, at each position', () => { + const found = resolveFlowNodeExpressions('screen', { + recordId: { dialect: 'cel', source: 'record.id' }, + defaults: { parent: ENVELOPE, stage: 'new', nested: { e: ENVELOPE } }, + fields: [ + { name: 'a', defaultValue: ENVELOPE, visibleWhen: 'x == 1' }, + { name: 'b', defaultValue: 'plain' }, + { name: 'c', defaultValue: { dialect: 'cel' } }, + ], + }); + expect(found.map((f) => [f.path, f.entry.role])).toEqual([ + ['fields[0].visibleWhen', 'predicate'], + ['defaults.parent', 'value'], + ['fields[0].defaultValue', 'value'], + ['fields[2].defaultValue', 'value'], + ['recordId', 'value'], + ]); + }); + + it('hands every authored value to the value-slot judge, strings included — an absent one skipped', () => { + expect(resolveFlowNodeValueSlots('screen', { + recordId: '{record.id}', + defaults: { a: '{x}', b: ENVELOPE, c: null }, + fields: [{ name: 'k', defaultValue: '{y}' }, { name: 'none' }], + }).map((f) => [f.path, f.value])).toEqual([ + ['defaults.a', '{x}'], ['defaults.b', ENVELOPE], ['defaults.c', null], + ['fields[0].defaultValue', '{y}'], + ['recordId', '{record.id}'], + ]); + }); +}); + describe('resolveFlowNodeValueSlots — every authored value of a `value` slot, strings included', () => { it('hands over every non-absent value of the CRUD `fields` map and the assignment map, by the ledger\'s own walk', () => { const envelope = { dialect: 'cel', source: 'price * 2' }; @@ -275,10 +326,13 @@ describe('every entry older than the value role resolves byte-identically (the r it('the entries that existed before are still declared exactly as they were — each value-slot widening only appended rows', () => { // The census: five rows before #19938, seven after, ten after #19939's - // callee maps. Each widening appended its `value` rows at the end — the - // CRUD write map's two, then `subflow.input`, `map.input` and - // `script.inputs` — and every row above them is byte-identical to what it - // was (`map.collection` keeps its `flow-template` row beside `map.input`). + // callee maps, thirteen after its screen trio. Each widening appended its + // `value` rows at the end — the CRUD write map's two, then `subflow.input`, + // `map.input` and `script.inputs`, then the screen's `defaults.*`, + // `fields[].defaultValue` and `recordId` — and every row above them is + // byte-identical to what it was (`map.collection` keeps its + // `flow-template` row beside `map.input`, `screen.fields[].visibleWhen` its + // `predicate` row beside the screen's value rows). expect(FLOW_NODE_EXPRESSION_PATHS.map(byKey)).toEqual([ 'screen.fields[].visibleWhen (predicate)', 'decision.conditions[].expression (predicate)', @@ -290,6 +344,9 @@ describe('every entry older than the value role resolves byte-identically (the r 'subflow.input.* (value)', 'map.input.* (value)', 'script.inputs.* (value)', + 'screen.defaults.* (value)', + 'screen.fields[].defaultValue (value)', + 'screen.recordId (value)', ]); }); diff --git a/packages/spec/src/automation/flow-text-slot-template.test.ts b/packages/spec/src/automation/flow-text-slot-template.test.ts index 3b25684878a..39b14378595 100644 --- a/packages/spec/src/automation/flow-text-slot-template.test.ts +++ b/packages/spec/src/automation/flow-text-slot-template.test.ts @@ -277,9 +277,23 @@ describe('ScreenConfigSchema — its two text slots compose the judge', () => { } }); - it('keeps the single-brace dialect on the value-like keys — a `recordId` names a record, not text', () => { - expect(ScreenConfigSchema.safeParse({ objectName: 'account', mode: 'edit', recordId: '{account_id}', defaults: { name: '{lead.company}' } }).success) - .toBe(true); + // RE-JUDGED IN PLACE (#19939, stage S2), not deleted. This pinned that the + // value-like keys kept the single brace. They are VALUE slots now: a `{token}` + // there is refused — by the value-slot judge, never by this text-slot one, + // because a `recordId` names a record and a prefill is a value, not text. + it('refuses the single brace on the value-like keys with the VALUE-slot remedy — a `recordId` names a record, not text', () => { + const refused = ScreenConfigSchema.safeParse({ objectName: 'account', mode: 'edit', recordId: '{account_id}', defaults: { name: '{lead.company}' } }); + expect(refused.success).toBe(false); + expect(refused.error?.issues.map((i) => i.path.join('.'))).toEqual(['recordId', 'defaults.name']); + for (const issue of refused.error!.issues) { + expect(issue.message).toContain(valueSlotTemplateRefusals(issue.path[0] === 'recordId' ? '{account_id}' : '{lead.company}')[0]!.message); + expect(issue.message).not.toContain('{{'); + } + expect(ScreenConfigSchema.safeParse({ + objectName: 'account', mode: 'edit', + recordId: { dialect: 'cel', source: 'account_id' }, + defaults: { name: { dialect: 'cel', source: 'lead.company' } }, + }).success).toBe(true); }); }); diff --git a/packages/spec/src/automation/flow-value-slot-template.test.ts b/packages/spec/src/automation/flow-value-slot-template.test.ts index 8932dd652ea..6b34e7cd13e 100644 --- a/packages/spec/src/automation/flow-value-slot-template.test.ts +++ b/packages/spec/src/automation/flow-value-slot-template.test.ts @@ -58,6 +58,19 @@ import { valueSlotTemplateRefusals, } from './flow-value-slot-template'; +/** + * A node config holding `value` at a value-slot ledger path — under key `k` + * for a map (`fields.*`), as the first element's key for an array + * (`fields[].defaultValue`), at the key itself for a single value + * (`recordId`). + */ +function configAtEntry(path: string, value: unknown): Record { + const [head, ...rest] = path.split('.'); + if (rest.length === 0) return { [head!]: value }; + if (head!.endsWith('[]')) return { [head!.slice(0, -2)]: [{ name: 'k', ...configAtEntry(rest.join('.'), value) }] }; + return { [head!]: rest[0] === '*' ? { k: value } : configAtEntry(rest.join('.'), value) }; +} + /** The one refusal a string draws, or a failure naming what came back. */ function refusalOf(value: unknown): string { const refusals = valueSlotTemplateRefusals(value); @@ -616,9 +629,9 @@ describe('a callee\'s input map: the refusal says what the guarded form hands th const valueEntries = FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.role === 'value'); expect(valueEntries.filter((e) => CALLEE.has(`${e.nodeType} ${e.path}`))).toHaveLength(3); for (const entry of valueEntries) { - const map = entry.path.slice(0, -2); - const message = at(entry.nodeType, map, 'k', '{x}'); - const carries = /is (the child flow|each item's child flow|the function)'s/.test(message); + const refusals = flowNodeValueTemplateRefusals(entry.nodeType, configAtEntry(entry.path, '{x}')); + expect(refusals, `${entry.nodeType} ${entry.path}`).toHaveLength(1); + const carries = /is (the child flow|each item's child flow|the function)'s/.test(refusals[0]!.message); expect(carries, `${entry.nodeType} ${entry.path}`).toBe(CALLEE.has(`${entry.nodeType} ${entry.path}`)); } }); @@ -633,6 +646,101 @@ describe('a callee\'s input map: the refusal says what the guarded form hands th }); }); +/** + * [#19939, stage S2] A screen's three value slots hand their value to the + * CLIENT — an object form's `defaults.*`, a field's `defaultValue`, an object + * form's `recordId` — evaluated before the screen goes on the wire. Where the + * template resolved a whole token to nothing it served nothing there; the + * refusal at a top-level value says what the guarded form serves instead + * (measured through the engine in `service-automation`'s + * `screen-value-slots.test.ts`). + */ +describe('a screen\'s value slots: the refusal says what the client is served instead of nothing', () => { + const one = (config: Record): { path: string; label: string; message: string } => { + const refusals = flowNodeValueTemplateRefusals('screen', config); + expect(refusals, JSON.stringify(config)).toHaveLength(1); + return refusals[0]!; + }; + + it('the three positions are judged, located and labelled by the ledger', () => { + expect(flowNodeValueTemplateRefusals('screen', { + objectName: 'account', mode: 'edit', recordId: '{record.id}', + defaults: { name: '{lead.company}', stage: 'new', tags: ['{lead.tag}'] }, + fields: [{ name: 'a', defaultValue: 'plain' }, { name: 'b', defaultValue: '{lead.email}' }], + title: 'Edit {{ record.name }}', + }).map((r) => [r.path, r.label])).toEqual([ + ['defaults.name', 'screen form default'], + ['defaults.tags[0]', 'screen form default'], + ['fields[1].defaultValue', 'screen field default value'], + ['recordId', 'screen record id'], + ]); + }); + + it('defaults: a path names the field, the object field\'s `defaultValue` and the `null` that opens it blank', () => { + const { message } = one({ objectName: 'opportunity', defaults: { account: '{account_id}' } }); + expect(message).toContain("{ dialect: 'cel', source: 'account_id' }"); + expect(message).toContain('`defaults.account` prefills the object form\'s `account` field.'); + expect(message).toContain('the form seeded that field from the object\'s declared `defaultValue`'); + expect(message).toContain('the guarded form prefills `null`, which the form keeps as a value and opens the field blank over that default'); + expect(message).toContain('To keep the default, write it in the guard\'s `null` branch'); + expect(message.indexOf('(the guarded form writes `null`)')).toBeLessThan(message.indexOf('prefills the object form')); + }); + + it('a field\'s defaultValue: the guarded form prefills `null`, a value submitted back', () => { + const { message } = one({ fields: [{ name: 'email', defaultValue: '{lead.email}' }] }); + expect(message).toContain('`fields[0].defaultValue` prefills this screen field.'); + expect(message).toContain('the guarded form prefills `null`, a value the field is seeded with and that is submitted back'); + expect(message).toContain('write it in the guard\'s `null` branch'); + }); + + it('recordId: it must evaluate to the id, a non-blank string — the guarded `null` fails the run, so route around', () => { + const { message } = one({ objectName: 'account', mode: 'edit', recordId: '{record.id}' }); + expect(message).toContain("{ dialect: 'cel', source: 'record.id' }"); + expect(message).toContain('`recordId` names the record an `edit` form opens, so the envelope must evaluate to that record\'s id, a non-blank string'); + expect(message).toContain('the guarded form\'s `null` included — fails the run at this screen'); + expect(message).toContain('route around the screen with a `decision`'); + }); + + it('a `$User` path that never resolved served nothing in EVERY run', () => { + expect(one({ objectName: 'a', defaults: { email: '{$User.Email}' } }).message) + .toContain('which the template prefilled with nothing in every run, so the form seeded it from the object\'s declared `defaultValue`: leaving the key out of `defaults` keeps exactly that.'); + expect(one({ fields: [{ name: 'e', defaultValue: '{$User.Email}' }] }).message) + .toContain('which the template prefilled with nothing in every run: leaving `defaultValue` out keeps exactly that.'); + expect(one({ objectName: 'a', mode: 'edit', recordId: '{$User.Name}' }).message) + .toContain('the template served this screen with no record id in every run'); + }); + + it('the run user\'s id and an expression carry it — each has an absent case', () => { + expect(one({ objectName: 'a', defaults: { owner: '{$User.Id}' } }).message).toContain('opens the field blank over that default'); + expect(one({ fields: [{ name: 'n', defaultValue: '{count + 1}' }] }).message).toContain('submitted back'); + }); + + it('no screen sentence where the remedy serves nothing new: text with holes, a date macro, braces that are neither', () => { + for (const value of ['Hi {name}', '{TODAY()}', '{#}']) { + for (const config of [{ defaults: { k: value } }, { fields: [{ name: 'k', defaultValue: value }] }, { recordId: value }]) { + expect(one(config).message, `${value} ${JSON.stringify(config)}`).not.toMatch(/prefills|names the record/); + } + } + }); + + it('no screen sentence inside a literal: the field takes the whole value, which the remedy builds', () => { + const { path, message } = one({ defaults: { meta: { owner: '{record.owner}' } } }); + expect(path).toBe('defaults.meta.owner'); + expect(message).not.toContain('prefills'); + }); + + it('only the screen\'s value slots carry it — one list: the ledger', () => { + const SCREEN = new Set(['screen defaults.*', 'screen fields[].defaultValue', 'screen recordId']); + const valueEntries = FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.role === 'value'); + expect(valueEntries.filter((e) => SCREEN.has(`${e.nodeType} ${e.path}`))).toHaveLength(3); + for (const entry of valueEntries) { + const [refusal] = flowNodeValueTemplateRefusals(entry.nodeType, configAtEntry(entry.path, '{x}')); + const carries = /prefills (the object form|this screen field)|names the record an `edit` form opens/.test(refusal!.message); + expect(carries, `${entry.nodeType} ${entry.path}`).toBe(SCREEN.has(`${entry.nodeType} ${entry.path}`)); + } + }); +}); + describe('ADR-0087 D2 — no measured spelling is converted; the refusal is what meets it', () => { it('the whole conversion chain, retired entries included, leaves every spelling as authored', () => { const fields = { From 2deda40a317cb0ff26076349f219d3ff6fb2cc39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 10:35:45 +0000 Subject: [PATCH 3/7] wip(service-automation): the screen node evaluates its three value slots before the screen goes on the wire (#19939 S2) Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- .../builtin/config-expression-ledger.test.ts | 28 +++ .../src/builtin/screen-nodes.ts | 81 ++++-- .../src/builtin/screen-value-slots.test.ts | 236 ++++++++++++++++++ .../src/builtin/text-slot-template.test.ts | 6 +- .../src/builtin/value-slot-map.ts | 32 ++- 5 files changed, 355 insertions(+), 28 deletions(-) create mode 100644 packages/services/service-automation/src/builtin/screen-value-slots.test.ts 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 73681d33760..f88ccc6fd09 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 @@ -336,6 +336,34 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => { expect(declaredFromDescriptors().map(key)).not.toContain(key(slot!)); }); + /** + * [#19939, stage S2] A `screen`'s three positions that hand a value to the + * client — `defaults.*`, a field's `defaultValue`, `recordId` — are `value` + * slots declared on the spec Zod channel (`ScreenConfigSchema`, carried by + * `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`): the descriptor keeps offering them + * as unmarked inputs, so neither channel declares one slot twice. + */ + it.each([ + ['defaults.*'], + ['fields[].defaultValue'], + ['recordId'], + ])('screen.%s is covered — a #19939 screen value slot, declared on the spec Zod channel', (path) => { + const slot = FLOW_NODE_EXPRESSION_PATHS.find((e) => e.nodeType === 'screen' && e.path === path); + expect(slot, 'the S2 slot: a screen prefill or record id may be a CEL envelope (#11182 ruling D)').toBeDefined(); + expect(slot!.role).toBe('value'); + expect(declaredFromSchemalessConfigs().map(key)).toContain(key(slot!)); + expect(declaredFromDescriptors().map(key)).not.toContain(key(slot!)); + }); + + it('screen keeps `fields[].visibleWhen` on its descriptor channel beside its value slots — one slot, one channel', () => { + expect(declaredFromDescriptors().filter((d) => d.nodeType === 'screen').map(key)).toEqual(['screen.fields[].visibleWhen (predicate)']); + expect(declaredFromSchemalessConfigs().filter((d) => d.nodeType === 'screen').map(key).sort()).toEqual([ + 'screen.defaults.* (value)', + 'screen.fields[].defaultValue (value)', + 'screen.recordId (value)', + ]); + }); + it('map keeps `collection` on its descriptor channel beside the new `input.*` — one slot, one channel', () => { expect(declaredFromDescriptors().filter((d) => d.nodeType === 'map').map(key)).toEqual(['map.collection (flow-template)']); expect(declaredFromSchemalessConfigs().filter((d) => d.nodeType === 'map').map(key)).toEqual(['map.input.* (value)']); diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index 64f8b2d5091..12c84f17a62 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -1,13 +1,13 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { PluginContext } from '@objectstack/core'; -import { defineActionDescriptor, ScreenConfigSchema, ScriptConfigSchema } from '@objectstack/spec/automation'; +import { defineActionDescriptor, isExpressionEnvelopeShaped, ScreenConfigSchema, ScriptConfigSchema } from '@objectstack/spec/automation'; import type { ScreenConfigParsed, ScriptConfigParsed } from '@objectstack/spec/automation'; import type { AutomationEngine } from '../engine.js'; import { flowScreenCopyKey, type FlowScreenCopyKey } from '@objectstack/spec/system'; -import { interpolate, renderTextSlot } from './template.js'; +import { renderTextSlot } from './template.js'; import { parseNodeConfig } from './parse-config.js'; -import { resolveValueSlotMap } from './value-slot-map.js'; +import { resolveValueSlot, resolveValueSlotMap } from './value-slot-map.js'; import { judgeHeadlessScreen } from '../screen-input-contract.js'; /** @@ -73,6 +73,16 @@ const LOOKUP_TARGET_COLUMN = { description: "Object whose records a `lookup` field picks from. Required on a `lookup` field — a picker with no target object resolves nothing.", }; +/** How a refusal names a value that is not a record id — `null`, a number (`42`), a map. */ +function valueKindOf(value: unknown): string { + if (value === null) return '`null`'; + if (value === undefined) return 'nothing'; + if (typeof value === 'string') return 'a blank string'; + if (Array.isArray(value)) return 'a list'; + if (typeof value === 'object') return 'a map'; + return `a ${typeof value} (\`${String(value)}\`)`; +} + export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext): void { // screen — server-side pass-through (input vars already injected by engine). engine.registerNodeExecutor({ @@ -90,8 +100,12 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext resumeAuthority: 'any', // Designer form (ADR-0018, #3304) — mirrors objectui's hardcoded `screen` // field group: flat input list OR an object form, plus title/description. - // `visibleWhen` is bare CEL (xExpression), `defaults` a free-form keyValue - // map (values may be `{var}` templates → `true`-permissive). + // `visibleWhen` is bare CEL (xExpression). `defaults` is a free-form + // keyValue map, and it, a field's `defaultValue` and `recordId` are + // value slots (#19939): their marker rides the spec Zod + // (`ScreenConfigSchema`, carried to the expression ledger by + // `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`), so the form keeps the inputs + // the Studio renders for them. configSchema: { type: 'object', properties: { @@ -124,7 +138,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext }, }, }, - defaultValue: { title: 'Default', description: 'Prefilled value. Interpolates {var} references.' }, + defaultValue: { title: 'Default', description: 'Prefilled value: a literal, or a CEL value envelope evaluated when the screen is served.' }, placeholder: { type: 'string', title: 'Placeholder' }, // Declared in #17306 — the bound pair, the help text and the // lookup target, spelled as the object field spells them. @@ -148,20 +162,22 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext // Declared in #4045: `mode: 'edit'` needs a record to edit, and the // executor reads exactly this key for it — but the form declared the // mode while offering no way to name its target. - recordId: { type: 'string', title: 'Record to edit', description: 'Object form only: id of the record `edit` mode opens. Interpolates {var} references.' }, - defaults: { type: 'object', additionalProperties: true, title: 'Form defaults', description: 'Object form only: prefilled values (e.g. account → {account_id}).' }, + recordId: { type: 'string', title: 'Record to edit', description: 'Object form only: id of the record `edit` mode opens — a string, or a CEL value envelope evaluated to the id when the screen is served.' }, + defaults: { type: 'object', additionalProperties: true, title: 'Form defaults', description: 'Object form only: prefilled values — each a literal, or a CEL value envelope evaluated when the screen is served (e.g. account → { dialect: \'cel\', source: \'account_id\' }).' }, }, }, }), async execute(node, variables, context) { - // Parsed BEFORE interpolation: the contract's typed slots are strings - // (or `unknown`, for the interpolatable `defaultValue`/`defaults`), so - // `{var}` templates pass the parse and resolve below as always. + // Parsed before anything is resolved: the text slots refuse a + // single-brace token (#22110), and the value slots — `recordId`, + // `defaults.*`, a field's `defaultValue` — refuse one too (#19939), so + // what reaches the resolution below is a `{{ }}` template, an envelope + // or a literal. const parsedCfg = parseNodeConfig('screen', node.id, ScreenConfigSchema, node.config); if (!parsedCfg.ok) return parsedCfg.refusal; const cfg = parsedCfg.config; // Screen config resolves against the live flow variables here (the - // engine does NOT pre-interpolate node config) — so a step's + // engine does NOT pre-resolve node config) — so a step's // title/description/record id/field-default/object-form-default can pull // from prior nodes. // @@ -169,9 +185,14 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext // `{{ }}` holes (`{{ lead_record.company }}`) through // {@link renderTextSlot} — the ONE text renderer the refusing `end` // node's `message` and a notification share (#15788: never a copy). - // The rest keep the single-brace dialect (`{account_id}`): `recordId` - // names a record and `defaults` / a field's `defaultValue` hand their - // resolved values over, not text. + // [#19939] The rest are VALUE slots, not text: `recordId` names a + // record and `defaults` / a field's `defaultValue` hand a value over. + // Each envelope is evaluated in the run's scope BEFORE the screen goes + // on the wire, so the client receives the value it computes — a record + // as a map, a list as a list, the id as a string — never the envelope; + // every other value is a literal, served as written. ⛔ No fallback to + // the single-brace interpolator: the dispatch is by shape + // (`resolveValueSlot`). // // [#22507] …in the run's language. Maintainer ruling A: a user-read // flow string the server renders per run is translated where it is @@ -210,9 +231,23 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext typeof cfg.description === 'string' && cfg.description.length > 0 ? screenText('description', cfg.description, 'the body text') : undefined; - const ref = (v: unknown): string | undefined => { - const resolved = interpolate(v, variables, context); - return resolved == null ? undefined : String(resolved); + // [#19939] The record an `edit` form opens. A literal is the contract's + // string, served as written. An envelope must evaluate to the record's + // id, a non-blank string: anything else — `null` included — fails the + // node here, naming its source, ⛔ never `String(value)` and never a + // screen served with no record (an `edit` form then opens with nothing + // read, and its save is refused). + const recordIdOf = (raw: unknown): string | undefined => { + if (raw === undefined) return undefined; + const id = resolveValueSlot(engine, raw, variables, 'recordId', context); + if (!isExpressionEnvelopeShaped(raw)) return id as string; + if (typeof id === 'string' && id.trim() !== '') return id; + throw new Error( + `recordId: the value expression evaluated to ${valueKindOf(id)}, not a record id. \`recordId\` names ` + + 'the record an `edit` form opens, so it must evaluate to that record\'s id, a non-blank string; ' + + 'where the record may be absent, route around this screen with a `decision` on the value — source: ' + + `\`${String((raw as { source?: unknown }).source ?? '')}\`.`, + ); }; // ── Object-form screen (master-detail wizards) ────────────────────── @@ -227,7 +262,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext if (objectName) { const defaults = cfg.defaults && typeof cfg.defaults === 'object' - ? (interpolate(cfg.defaults, variables, context) as Record) + ? resolveValueSlotMap(engine, cfg.defaults, variables, 'defaults', context) : undefined; const idVariable = typeof cfg.idVariable === 'string' && cfg.idVariable.trim() ? cfg.idVariable.trim() : undefined; @@ -241,7 +276,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext description: body(), objectName, mode: cfg.mode === 'edit' ? 'edit' : 'create', - recordId: cfg.recordId != null ? ref(cfg.recordId) : undefined, + recordId: recordIdOf(cfg.recordId), defaults, idVariable, fields: [], @@ -284,13 +319,15 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext if (!shouldPause) { return { success: true }; } - const fields = rawFields.map((f) => ({ + const fields = rawFields.map((f, index) => ({ name: f.name, label: f.label, type: f.type, required: f.required === true, options: f.options as Array<{ value: unknown; label: string }> | undefined, - defaultValue: f.defaultValue !== undefined ? interpolate(f.defaultValue, variables, context) : undefined, + defaultValue: f.defaultValue !== undefined + ? resolveValueSlot(engine, f.defaultValue, variables, `fields[${index}].defaultValue`, context) + : undefined, placeholder: f.placeholder, // #17306. `min`/`max` go on the wire for the client to apply at the // input, and are re-checked server-side on resume diff --git a/packages/services/service-automation/src/builtin/screen-value-slots.test.ts b/packages/services/service-automation/src/builtin/screen-value-slots.test.ts new file mode 100644 index 00000000000..8b2e58279dc --- /dev/null +++ b/packages/services/service-automation/src/builtin/screen-value-slots.test.ts @@ -0,0 +1,236 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **A screen's three positions that hand a value to the client are value + * slots** — an object form's `defaults.*`, a field's `defaultValue` and an + * object form's `recordId` (#19939, the rider positions' second stage; #11182 + * ruling D; ADR-0032 Decision 2, a computed value is whole-field CEL). + * Declared in the expression ledger and evaluated in the same change. + * + * Before this, the executor handed each to `interpolate()`: a `{token}` + * resolved, an envelope written at `defaults.*` or `defaultValue` went on the + * wire as the object `{ dialect: 'cel', source: '…' }` it spells, and + * `recordId` was `String(resolved)` of whatever the token found. Pinned + * through the shipped `AutomationEngine`, on the screen the paused run serves: + * + * 1. **Refused at every door.** A `{token}` at each position stops the flow + * registering, located at `config.` and naming its CEL envelope; the + * executor's contract parse refuses the same token as a guard. + * 2. **Evaluated before the screen goes on the wire.** Each envelope is + * evaluated in the run's scope, and the client receives the value — a + * record stays a map, a list a list, the id is a string — never the + * envelope. + * 3. **Absent and `null`.** A whole token that resolved to nothing served + * nothing (the key left off the wire). Under CEL an absent value fails the + * run at the screen, with its source; the guarded form serves `null` at + * `defaults.*` and `defaultValue`. `recordId` must evaluate to the record's + * id, a non-blank string: anything else, `null` included, fails the run — + * ⛔ never `String(value)`, never an `edit` form served with no record. + */ + +import { describe, expect, it } from 'vitest'; +import { VALUE_ENVELOPE_REFUSAL, VALUE_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation'; +import { AutomationEngine } from '../engine.js'; +import { installBuiltinNodes } from './index.js'; +import { interpolate } from './template.js'; + +function silentLogger(): any { + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } }; +} +function ctx(): any { + return { logger: silentLogger(), getService() { return undefined; } }; +} + +function makeEngine(): AutomationEngine { + const engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, ctx()); + return engine; +} + +/** The builtin executors themselves — the run-time door, reached without `registerFlow`. */ +function screenExecutor(): { execute: (...args: any[]) => Promise } { + const executors = new Map Promise }>(); + const engine = new AutomationEngine(silentLogger()); + const recorder = new Proxy(engine, { + get(target, key, receiver) { + if (key === 'registerNodeExecutor') return (e: any) => { executors.set(e.type, e); }; + return Reflect.get(target, key, receiver); + }, + }); + installBuiltinNodes(recorder as AutomationEngine, ctx()); + return executors.get('screen')!; +} + +const VARIABLES = [ + { name: 'rec', type: 'object', isInput: true }, + { name: 'rows', type: 'list', isInput: true }, + { name: 'account_id', type: 'text', isInput: true }, +]; + +/** start → a `screen` node carrying `config` → end. */ +function screenFlow(name: string, config: Record): any { + return { + name, label: name, type: 'screen', variables: VARIABLES, + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'form', type: 'screen', label: 'Form', config }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'form' }, { id: 'e2', source: 'form', target: 'end' }], + }; +} + +const REC = { id: 'acc_1', name: 'Acme', tier: 'gold' }; +const ROWS = [{ id: 'c1' }, { id: 'c2' }]; +const PARAMS = { params: { rec: REC, rows: ROWS, account_id: 'acc_9' } } as any; +const CEL = (source: string) => ({ dialect: 'cel', source }); + +/** Run `config` as a one-screen flow and hand back what the paused run serves — or the failed run. */ +async function serve(config: Record, context: any = PARAMS): Promise { + const engine = makeEngine(); + engine.registerFlow('screen_probe', screenFlow('screen_probe', config)); + return engine.execute('screen_probe', context); +} + +/** The three positions: what an author writes there, and where the refusal locates it. */ +const POSITIONS = [ + ['recordId', (v: unknown) => ({ objectName: 'account', mode: 'edit', recordId: v }), 'recordId'], + ['defaults.*', (v: unknown) => ({ objectName: 'account', defaults: { account: v } }), 'defaults.account'], + ['fields[].defaultValue', (v: unknown) => ({ fields: [{ name: 'email', type: 'text', defaultValue: v }] }), 'fields[0].defaultValue'], +] as const; + +describe('1. a `{token}` at a screen\'s value slots is refused at every door', () => { + it.each(POSITIONS)('%s: `registerFlow` refuses it at `config.%s`, naming the envelope and what the client is served', (_what, configOf, at) => { + let message = ''; + try { + makeEngine().registerFlow('refused', screenFlow('refused', configOf('{account_id}'))); + } catch (e) { + message = (e as Error).message; + } + expect(message).toContain(`at config.${at}: ${VALUE_SLOT_TEMPLATE_REFUSAL}`); + expect(message).toContain("{ dialect: 'cel', source: 'account_id' }"); + expect(message).toMatch(/prefills (the object form's `account` field|this screen field)|names the record an `edit` form opens/); + }); + + it.each(POSITIONS)('%s: the executor\'s contract parse refuses it too, as a guard', async (_what, configOf, at) => { + const result = await screenExecutor().execute( + { id: 'form', type: 'screen', label: 'Form', config: configOf('{account_id}') }, + new Map([['account_id', 'acc_9']]), + {}, + ); + expect(result.success).toBe(false); + expect(result.errorClass).toBe('guard'); + expect(result.error).toContain(`config.${at}: ${VALUE_SLOT_TEMPLATE_REFUSAL}`); + }); + + it.each(POSITIONS)('%s: an envelope whose source is not CEL is refused at `registerFlow` — the ledger routes it to the envelope pass', (_what, configOf, at) => { + let message = ''; + try { + makeEngine().registerFlow('refused', screenFlow('refused', configOf(CEL('rec.name +')))); + } catch (e) { + message = (e as Error).message; + } + expect(message).toContain(`at config.${at}: ${VALUE_ENVELOPE_REFUSAL}`); + }); +}); + +describe('2. each envelope is evaluated before the screen goes on the wire — the client receives the value', () => { + it('object form: `recordId` is served as the string id, `defaults` as the values — a record a map, a list a list', async () => { + const paused = await serve({ + objectName: 'account', mode: 'edit', + recordId: CEL('rec.id'), + defaults: { parent: CEL('rec'), contacts: CEL('rows'), label: CEL("'Hi ' + rec.name"), stage: 'qualification', n: 3 }, + }); + expect(paused.status, paused.error).toBe('paused'); + expect(paused.screen.kind).toBe('object-form'); + expect(paused.screen.recordId).toBe('acc_1'); + expect(paused.screen.defaults).toEqual({ parent: REC, contacts: ROWS, label: 'Hi Acme', stage: 'qualification', n: 3 }); + expect(Array.isArray(paused.screen.defaults.contacts)).toBe(true); + // What goes on the wire carries no envelope at all. + expect(JSON.stringify(paused.screen)).not.toContain('"dialect"'); + }); + + it('flat screen: each field\'s `defaultValue` is served as the value it computes, literals as written', async () => { + const paused = await serve({ + fields: [ + { name: 'tier', type: 'text', defaultValue: CEL('rec.tier') }, + { name: 'contacts', type: 'text', defaultValue: CEL('rows') }, + { name: 'record', type: 'text', defaultValue: CEL('rec') }, + { name: 'priority', type: 'text', defaultValue: 'normal' }, + { name: 'none', type: 'text' }, + ], + }); + expect(paused.status, paused.error).toBe('paused'); + expect(paused.screen.fields.map((f: { defaultValue: unknown }) => f.defaultValue)) + .toEqual(['gold', ROWS, REC, 'normal', undefined]); + expect(JSON.stringify(paused.screen)).not.toContain('"dialect"'); + }); + + it('a literal `recordId` is served as written', async () => { + const paused = await serve({ objectName: 'account', mode: 'edit', recordId: 'acc_7' }); + expect(paused.screen.recordId).toBe('acc_7'); + }); + + it('a pass-through screen goes on no wire, so nothing on it is evaluated', async () => { + const run = await serve({ waitForInput: false, fields: [{ name: 'x', type: 'text', defaultValue: CEL('nope') }] }); + expect(run.success, run.error).toBe(true); + expect(run.status).not.toBe('paused'); + }); +}); + +describe('3. absent and `null` — what each position serves, and where the run fails', () => { + const GUARDED = CEL('has(vars.nope) ? vars.nope : null'); + + it('what the template did: a whole token that resolved to nothing served nothing — the key leaves the wire', () => { + const served = interpolate({ account: '{nope}' }, new Map(), {} as any) as Record; + expect(served).toEqual({ account: undefined }); + expect(JSON.parse(JSON.stringify(served))).toEqual({}); + }); + + it.each(POSITIONS)('%s: an unguarded absent value fails the run at the screen, naming its source — nothing is served', async (_what, configOf, at) => { + const run = await serve(configOf(CEL('nope'))); + expect(run.success).toBe(false); + expect(run.status).not.toBe('paused'); + expect(run.error).toContain(`${at}: value expression failed to evaluate as CEL`); + expect(run.error).toContain('source: `nope`'); + }); + + it('defaults: the guarded form serves `null` — a value on the wire, where the template served no key', async () => { + const paused = await serve({ objectName: 'account', defaults: { account: GUARDED, stage: 'new' } }); + expect(paused.screen.defaults).toEqual({ account: null, stage: 'new' }); + expect(JSON.parse(JSON.stringify(paused.screen.defaults))).toEqual({ account: null, stage: 'new' }); + }); + + it('defaults: writing the default in the guard serves it', async () => { + const paused = await serve({ objectName: 'account', defaults: { stage: CEL("has(vars.nope) ? vars.nope : 'prospecting'") } }); + expect(paused.screen.defaults).toEqual({ stage: 'prospecting' }); + }); + + it('a field\'s defaultValue: the guarded form serves `null`', async () => { + const paused = await serve({ fields: [{ name: 'email', type: 'text', defaultValue: GUARDED }] }); + expect(paused.screen.fields[0].defaultValue).toBeNull(); + expect(JSON.parse(JSON.stringify(paused.screen.fields[0]))).toHaveProperty('defaultValue', null); + }); + + it.each([ + ['`null` — the guarded form', GUARDED, '`null`'], + ['a map', CEL('rec'), 'a map'], + ['a list', CEL('rows'), 'a list'], + ['a blank string', CEL("' '"), 'a blank string'], + ] as const)('recordId: an envelope that evaluates to %s fails the run at the screen — never `String(value)`, never an edit form with no record', async (_what, envelope, kind) => { + const run = await serve({ objectName: 'account', mode: 'edit', recordId: envelope }); + expect(run.success).toBe(false); + expect(run.status).not.toBe('paused'); + expect(run.error).toContain(`recordId: the value expression evaluated to ${kind}, not a record id.`); + expect(run.error).toContain('route around this screen with a `decision` on the value'); + expect(run.error).toContain(`source: \`${envelope.source}\``); + expect(run.error).not.toContain('[object Object]'); + }); + + it('recordId: the template\'s `String(resolved)` is gone — a record the token found is no id', async () => { + // The 17.x reader stringified whatever the token resolved to. + expect(String(interpolate('{rec}', new Map([['rec', REC]]), {} as any))).toBe('[object Object]'); + const run = await serve({ objectName: 'account', mode: 'edit', recordId: CEL('rec') }); + expect(run.success).toBe(false); + }); +}); diff --git a/packages/services/service-automation/src/builtin/text-slot-template.test.ts b/packages/services/service-automation/src/builtin/text-slot-template.test.ts index 75bf713106d..20e12f1f191 100644 --- a/packages/services/service-automation/src/builtin/text-slot-template.test.ts +++ b/packages/services/service-automation/src/builtin/text-slot-template.test.ts @@ -195,13 +195,15 @@ describe('#22110 — the doors around a text slot', () => { expect(emitted[0]!.payload).toMatchObject({ title: 'Push failed for Acme Corp', body: 'Reason: connection refused' }); }); - it('a screen renders its title / description holes, and its `recordId` keeps the single-brace dialect', async () => { + // [#19939] `recordId` is a value slot now: the id is a CEL envelope, + // evaluated before the screen goes on the wire (`screen-value-slots.test.ts`). + it('a screen renders its title / description holes, and its `recordId` envelope is evaluated to the id', async () => { const { engine } = harness(); engine.registerFlow('edit', { name: 'edit', label: 'edit', type: 'screen', nodes: [ { id: 'start', type: 'start', label: 'Start' }, - { id: 'form', type: 'screen', label: 'Edit', config: { objectName: 'account', mode: 'edit', recordId: '{record.id}', title: 'Edit {{ record.name }}', description: 'Tier {{ record.meta.tier | upper }}' } }, + { id: 'form', type: 'screen', label: 'Edit', config: { objectName: 'account', mode: 'edit', recordId: { dialect: 'cel', source: 'record.id' }, title: 'Edit {{ record.name }}', description: 'Tier {{ record.meta.tier | upper }}' } }, ], edges: [{ id: 'e0', source: 'start', target: 'form' }], } as never); diff --git a/packages/services/service-automation/src/builtin/value-slot-map.ts b/packages/services/service-automation/src/builtin/value-slot-map.ts index bddc322bc6c..26cf0ec5bfc 100644 --- a/packages/services/service-automation/src/builtin/value-slot-map.ts +++ b/packages/services/service-automation/src/builtin/value-slot-map.ts @@ -16,7 +16,10 @@ import { interpolate, type VariableMap } from './template.js'; * - `map` `input` (#19939) — handed to each item's child flow, resolved once * per item with the item (and index) variable bound in `variables`; * - `script` `inputs` (#19939) — handed to the registered function as its - * `input`. + * `input`; + * - `screen` `defaults` (#19939) — an object form's prefill, served to the + * client (and, through {@link resolveValueSlot}, a field's `defaultValue` + * and an object form's `recordId`). * * Per key, by SHAPE — the rule the ledger resolver and the spec contract * (`FlowValueSlotSchema`) draw with the same predicate, imported rather than @@ -63,9 +66,30 @@ export function resolveValueSlotMap( ): Record { const out: Record = {}; for (const [key, value] of Object.entries(map ?? {})) { - out[key] = isExpressionEnvelopeShaped(value) - ? engine.evaluateValueEnvelope(value, variables, `${slot}.${key}`, context) - : interpolate(value, variables, context); + out[key] = resolveValueSlot(engine, value, variables, `${slot}.${key}`, context); } return out; } + +/** + * Resolve ONE value-slot value — the per-key rule of + * {@link resolveValueSlotMap}, for a slot that holds a single value rather than + * a map: since #19939 a `screen` field's `defaultValue` and an object form's + * `recordId` (`fields[].defaultValue`, `recordId` in the ledger), evaluated + * before the screen goes on the wire. The map resolver calls this per key, so + * the dispatch by shape is spelled once: an envelope-shaped value is evaluated + * by `AutomationEngine.evaluateValueEnvelope` (`where` names it in a refusal + * or a fault, `fields[0].defaultValue`), and every other value is a literal, + * handed on as a copy. + */ +export function resolveValueSlot( + engine: AutomationEngine, + value: unknown, + variables: VariableMap, + where: string, + context: AutomationContext, +): unknown { + return isExpressionEnvelopeShaped(value) + ? engine.evaluateValueEnvelope(value, variables, where, context) + : interpolate(value, variables, context); +} From 53414eac55f1d03c979e18cdd0937c4a5ff0c109 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 10:37:15 +0000 Subject: [PATCH 4/7] wip: lint pins, the CRM convert-lead prefills, flows.mdx and the dropped-refinement ledger (#19939 S2) Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- content/docs/automation/flows.mdx | 45 ++++++++++++++----- .../app-crm/src/flows/convert-lead.flow.ts | 16 +++++-- packages/lint/src/lint-flow-patterns.test.ts | 31 +++++++++++++ .../validate-expressions.text-slot.test.ts | 3 +- .../spec/dropped-refinements.baseline.json | 9 ++-- 5 files changed, 85 insertions(+), 19 deletions(-) diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 6bb6d9a5758..87035f68907 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -230,9 +230,12 @@ The same rules hold for the field values of `create_record` / `update_record` `map` node's `input` (the child flow's input variables — on a `map`, evaluated once per item with the item variable bound) and a `script` node's `inputs` (the registered function's `input`). Those maps — `assignments`, `fields`, `input`, -`inputs` — are the flow's **value slots**, and each value takes a CEL value -envelope or a literal. Only a slot's top-level value is an expression — an -object nested inside a JSON value or an array is data, whatever keys it carries. +`inputs` — are the flow's **value slots**, and so are a `screen` node's three +positions that hand a value to the client: an object form's `defaults` (each +field's prefill), a field's `defaultValue`, and an object form's `recordId`. +Each value takes a CEL value envelope or a literal. Only a slot's top-level +value is an expression — an object nested inside a JSON value or an array is +data, whatever keys it carries. @@ -295,9 +298,29 @@ the key out of `input` where the value is never meant to be supplied. A `script` function is handed `null` there, where the template handed `undefined`. The refusal at those positions says so. +**A screen's prefill and record id: evaluated before the screen is served.** On +a `screen` node, each envelope in `defaults`, a field's `defaultValue` and +`recordId` is evaluated in the run's scope before the screen goes to the client, +so the client receives the value — a record as a map, a list as a list — never +the envelope. Where a whole token resolved to nothing, the template served +nothing there; the guarded form serves `null`: + +- in an object form's `defaults`, the form keeps a `null` prefill as a value and + opens the field blank — over the object field's own `defaultValue`, which it + applied when the prefill left the field out. Write that default in the guard + (`has(vars.x) ? vars.x : 'prospecting'`) to keep it; +- a field's `defaultValue` seeds the field with `null`; +- `recordId` names the record an `edit` form opens, so its envelope must + evaluate to the record's id, a non-blank string: anything else, `null` + included, **fails the run at the screen**. The template served such a screen + with no record id, and its `edit` form opened with no record and could not + save. Where the record may be absent, route around the screen with a + `decision` on the value instead of guarding the id. + **Where an envelope is data, the remedy names none there.** An envelope -evaluates only as the top-level value of a value slot's map — `fields`, -`assignments`, or a callee's `input` / `inputs`. Inside an object or list value, and in the two legacy `assignment` shapes +evaluates only as the top-level value of a value slot — `fields`, +`assignments`, a callee's `input` / `inputs`, or a screen's `defaults`, +`defaultValue` or `recordId`. Inside an object or list value, and in the two legacy `assignment` shapes (an `assignments: [{ variable, value }]` array, variables as the config's own keys), it is stored as the object it spells — so the refusal there names what does evaluate: @@ -645,9 +668,9 @@ bound to `config.idVariable` so a later step can reference it. config: { objectName: 'opportunity', // render this object's full form mode: 'create', // 'create' (default) | 'edit' - // recordId: '{account_id}', // required for mode: 'edit' - defaults: { // pre-filled values (interpolated) - account: '{account_id}', + // recordId: { dialect: 'cel', source: 'opportunity_id' }, // required for mode: 'edit' + defaults: { // pre-filled values: a CEL envelope or a literal each + account: { dialect: 'cel', source: 'account_id' }, stage: 'prospecting', }, idVariable: 'opportunity_id', // bind the saved record's id for later steps @@ -2027,8 +2050,8 @@ CEL, and every computed value (a field value, an assignment value) is a CEL value envelope. **Text** slots — a `notify` title or message, a screen title or description, a refusing `end` node's message — are `{{ }}` templates (see [text slots](#text-slots-read-double-brace-holes)). Single braces remain only on the -value-like positions that hand a resolved value over (`recipients`, -`sourceId`, a `subflow` input, an `http` body, …). +value-like positions that hand a resolved value over and that the retirement +has not reached yet (`recipients`, `sourceId`, an `http` body, …). | Where | Dialect | Write it like | Bindings | |:---|:---|:---|:---| @@ -2069,7 +2092,7 @@ and is read as `vars["current_user"]` / `vars["vars"]`. braces. 3. **A `{…}` template in a value slot** — `total: '{round(x * 100) / 100}'`, `owner: '{record.owner}'` — is refused with the CEL spelling to write - instead. In a single-brace position (an `http` body, a `subflow` input), an + instead. In a single-brace position (an `http` body, a `notify` recipient), an unsupported function in braces — `'{ROUND(x, 2)}'`, `'{Math.round(x)}'`, `'{(x).toFixed(2)}'` — fails the node with a **named error** listing the supported set and, where one is close, the spelling you meant. Before #11060 diff --git a/examples/app-crm/src/flows/convert-lead.flow.ts b/examples/app-crm/src/flows/convert-lead.flow.ts index 92eeb7734dc..76dfda2f966 100644 --- a/examples/app-crm/src/flows/convert-lead.flow.ts +++ b/examples/app-crm/src/flows/convert-lead.flow.ts @@ -107,8 +107,13 @@ export const ConvertLeadScreenFlow = defineFlow({ idVariable: 'account_id', title: 'Step 1 of 2 · Customer', description: 'Review and complete the customer record carried over from the lead.', + // Each prefill is a value slot: a CEL value envelope, evaluated before + // the screen is served, so the form receives the value. `lead_record` + // is bound by `get_lead` (the decision above reads it), but `company` + // is optional on a lead, so its absence is guarded: the form opens + // the name blank, as it did when the template found nothing. defaults: { - name: '{lead_record.company}', + name: { dialect: 'cel', source: 'has(lead_record.company) ? lead_record.company : null' }, }, }, }, @@ -127,10 +132,13 @@ export const ConvertLeadScreenFlow = defineFlow({ idVariable: 'opportunity_id', title: 'Step 2 of 2 · Opportunity', description: 'Add the opportunity and its product line items. Saved together in one transaction.', + // `name` is required on a lead, and `account_id` is bound by the + // Customer step's `idVariable` before this step runs, so neither needs + // a `has()` guard. defaults: { - name: '{lead_record.name}', - account: '{account_id}', - stage: 'qualification', + name: { dialect: 'cel', source: 'lead_record.name' }, + account: { dialect: 'cel', source: 'account_id' }, + stage: 'qualification', }, }, }, diff --git a/packages/lint/src/lint-flow-patterns.test.ts b/packages/lint/src/lint-flow-patterns.test.ts index 083f9fca9bd..0d3c348671d 100644 --- a/packages/lint/src/lint-flow-patterns.test.ts +++ b/packages/lint/src/lint-flow-patterns.test.ts @@ -2722,6 +2722,24 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule .toBe(1); }); + // [#19939, stage S2] A screen's prefill and record id joined the value + // slots: the hint flips there by asking the judge — no line of its own — + // with the sentence the judge gives at that position. + it.each([ + ['defaults', 'screen form default', (v: unknown) => ({ objectName: 'task', defaults: { who: v } }), 'prefills the object form\'s `who` field'], + ['field defaultValue', 'screen field default value', (v: unknown) => ({ fields: [{ name: 'who', type: 'text', defaultValue: v }] }), 'prefills this screen field'], + ['recordId', 'screen record id', (v: unknown) => ({ objectName: 'task', mode: 'edit', recordId: v }), 'names the record an `edit` form opens'], + ] as const)('screen %s: names the CEL envelope and what the client is served, not `{source.id}`', (_what, label, configOf, sentence) => { + const [f] = bareDollar(valueFlow('screen', configOf('$source.id'))); + expect(f!.message).toContain(`in the ${label}`); + expect(f!.hint).toContain('A value slot reads a CEL value envelope'); + expect(f!.hint).not.toContain('Wrap it and bind a variable'); + expect(prescribedSources(f!.hint!)).toEqual(['source.id']); + expect(f!.hint).toContain(sentence); + expect(refusalsOf(valueFlow('screen', configOf('{source.id}'))).valueJudge).toBe(1); + expect(refusalsOf(valueFlow('screen', configOf(CEL('source.id'))))).toEqual(NONE); + }); + it('control: both spellings the hint used to prescribe are refused in the same slot', () => { for (const old of ['{source.id}', '{$User.Id}']) { expect(refusalsOf(valueFlow('create_record', { objectName: 'task', fields: { v: old } })).valueJudge, old).toBe(1); @@ -2854,6 +2872,19 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule expect(refusalsOf(valueFlow(type, configOf(CEL("'Re: ' + record.title"))))).toEqual(NONE); }); + // [#19939, stage S2] …and at a screen's value slots. + it.each([ + ['defaults', 'screen form default', (v: unknown) => ({ objectName: 'task', defaults: { subject: v } })], + ['field defaultValue', 'screen field default value', (v: unknown) => ({ fields: [{ name: 'subject', type: 'text', defaultValue: v }] })], + ['recordId', 'screen record id', (v: unknown) => ({ objectName: 'task', mode: 'edit', recordId: v })], + ] as const)('in a screen %s: names the envelope for the hole it means', (_what, label, configOf) => { + const [f] = doubleBrace(valueFlow('screen', configOf('Re: {{ record.title }}'))); + expect(f!.message).toContain(`the ${label} reads a CEL value envelope, not a template`); + expect(f!.hint).not.toContain('Use `{var}`'); + expect(prescribedSources(f!.hint!)).toEqual(["'Re: ' + record.title"]); + expect(refusalsOf(valueFlow('screen', configOf(CEL("'Re: ' + record.title"))))).toEqual(NONE); + }); + it('control: outside the value and text slots the single brace still resolves, and the double-brace hint still names it', () => { const [f] = lintFlowPatterns(httpFlow({ title: '{{ record.title }}' })).filter((x) => x.rule === FLOW_DOUBLE_BRACE_INTERP); expect(f!.hint).toContain('Use `{var}` (e.g. `{record.title}`)'); diff --git a/packages/lint/src/validate-expressions.text-slot.test.ts b/packages/lint/src/validate-expressions.text-slot.test.ts index a942403c9f1..6a718519232 100644 --- a/packages/lint/src/validate-expressions.text-slot.test.ts +++ b/packages/lint/src/validate-expressions.text-slot.test.ts @@ -118,7 +118,8 @@ describe('`objectstack validate` — a flow text slot reads `{{ }}` holes (#2211 title: 'Deal {{ record.name }} won: {{ record.amount | currency }}', message: 'Failed: {{ $error.message }}', })).toEqual([]); - expect(validate('screen', { objectName: 'deal', mode: 'edit', recordId: '{record.id}', title: 'Edit {{ record.name }}' })).toEqual([]); + // [#19939] A screen's `recordId` is a value slot now: its id is a CEL envelope. + expect(validate('screen', { objectName: 'deal', mode: 'edit', recordId: { dialect: 'cel', source: 'record.id' }, title: 'Edit {{ record.name }}' })).toEqual([]); }); it('refuses `By {{ $User.Id }}` in a text slot at `error`, with the remedy `{$User.Id}` gets (#22477)', () => { diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 3e28e0e27a9..7b3bb7f8e5f 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 226, - "droppedRefinementSites": 705, + "droppedRefinementSites": 708, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -544,12 +544,15 @@ "automation/ScreenConfig": { "sites": [ "", - "fields.element" + "defaults.valueType", + "fields.element", + "recordId" ] }, "automation/ScreenFieldConfig": { "sites": [ - "" + "", + "defaultValue" ] }, "automation/ScriptConfig": { From 71f01503ee2e237ccdf69ea95304c9d6e8386797 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 10:45:32 +0000 Subject: [PATCH 5/7] wip: changeset and the regenerated builtin-node-config reference (#19939 S2) Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- .changeset/19939-flow-screen-value-slots.md | 46 +++++++++++++++++++ .../automation/builtin-node-config.mdx | 13 +++--- 2 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 .changeset/19939-flow-screen-value-slots.md diff --git a/.changeset/19939-flow-screen-value-slots.md b/.changeset/19939-flow-screen-value-slots.md new file mode 100644 index 00000000000..4eee580ccf3 --- /dev/null +++ b/.changeset/19939-flow-screen-value-slots.md @@ -0,0 +1,46 @@ +--- +'@objectstack/spec': major +'@objectstack/service-automation': major +--- + +A `screen` node's three positions that hand a value to the client are value slots now: an object form's `defaults` (each field's prefill), a field's `defaultValue`, and an object form's `recordId` (the record an `edit` form opens). Each value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`, that the executor evaluates in the run's scope before the screen goes on the wire, so the client receives the value it computes, never the envelope; or a literal, served as written. A `{…}` template token there is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling, as in every other value slot. `recordId` gains the envelope arm, and its literal stays a string. + +Clause-②: no (narrowing) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + +**Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. A screen's prefill and its record id are computed values handed to the client. Until now the executor ran each through the single-brace interpolator: a `{token}` resolved there, an envelope written in `defaults` or a `defaultValue` went to the client as the object `{ dialect: 'cel', source: '…' }` it spells, and `recordId` was the text of whatever the token found, so a token that found a record served the id `[object Object]`. + +**What changes where a value may be absent.** Where a whole token resolved to nothing, the template served nothing there. Under CEL an absent variable or key fails the run at the screen, with its source, and the guarded form `has(vars.x) ? vars.x : null` serves `null`: + +- In an object form's `defaults`, the form keeps a `null` prefill as a value and opens the field blank, over the object field's own `defaultValue`, which it applied when the prefill left the field out. To keep that default for an absent value, write it in the guard: `has(vars.x) ? vars.x : 'prospecting'`. +- A field's `defaultValue` seeds the field with `null`. +- `recordId` must evaluate to the record's id, a non-blank string. Anything else, `null` included, fails the run at the screen. The template served such a screen with no record id, and its `edit` form opened with no record and could not save. Where the record may be absent, route around the screen with a `decision` on the value instead of guarding the id. + +The refusal at each position says what it serves. + +## FROM → TO + +| you wrote | write instead | what changes | +|:--|:--|:--| +| `defaults: { account: '{account_id}' }` | `defaults: { account: { dialect: 'cel', source: 'account_id' } }` | an absent `account_id` fails the run; guarded, it prefills `null` and the field opens blank over the object field's `defaultValue` | +| `defaults: { stage: '{x}' }` where the object field's default should apply when `x` is absent | `defaults: { stage: { dialect: 'cel', source: "has(vars.x) ? vars.x : 'prospecting'" } }` | the default is written in the guard, because a `null` prefill is a value | +| `defaults: { parent: '{rec}' }` | `defaults: { parent: { dialect: 'cel', source: 'rec' } }` | the record is served as a map, a list as a list | +| `fields: [{ name: 'email', defaultValue: '{lead.email}' }]` | `fields: [{ name: 'email', defaultValue: { dialect: 'cel', source: 'lead.email' } }]` | guarded, it prefills `null` | +| `recordId: '{record.id}'` | `recordId: { dialect: 'cel', source: 'record.id' }` | the envelope must evaluate to a non-blank string id: `null`, a map or a list fails the run at the screen | +| `recordId: 'acc_1'` | unchanged | a string literal is the id | + +**The one-line fix: write each value of a screen's `defaults`, each field's `defaultValue` and an object form's `recordId` as a CEL value envelope; write the object field's default into the guard where it should apply, and route around a screen whose record may be absent.** + +**Who is affected, measured.** In this repository: the CRM example's lead conversion (`crm_convert_lead_wizard`: 3 `defaults` values in its two object-form steps), the object-form example in the flows guide, and test fixtures; all are migrated in this change. hotcrm's sites at these positions were not measured. + +**Still accepted, unchanged.** A CEL value envelope and every literal, a string `recordId` included. The single-brace dialect keeps resolving where it still lives: a `map` or `loop` `collection`, a `filter` value, a `notify` `recipients` entry, an `http` body. The value-slot retirement's earlier changesets on this line list a screen's `defaults` among those positions; this one supersedes that line. + +### The kit + +- **The contract.** `ScreenConfigSchema.defaults` and a field's `defaultValue` take `FlowValueSlotSchema`, and `recordId` takes a string or a CEL value envelope (`@objectstack/spec/automation`), so the executor's contract parse refuses a token as a guard. Each published JSON Schema declares the dropped refinement (`dropped-refinements.baseline.json`). +- **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains `screen.defaults.*`, `screen.fields[].defaultValue` and `screen.recordId` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries `ScreenConfigSchema`. `registerFlow`, `objectstack validate` and `flow-bare-dollar-reference` / `flow-double-brace-interpolation`'s value-slot hints follow the ledger, so all of them cover the three positions. The build doors keep refusing a `recordId` literal that is not a string, as before. +- **The executor.** The `screen` node resolves each position through the value-slot resolver the CRUD `fields` map and a callee's input share: an envelope is evaluated by `AutomationEngine.evaluateValueEnvelope` in the run's scope, before the screen is served. A screen that passes through without pausing goes on no wire, and evaluates nothing. +- **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index 6317182867e..19d2507f6a3 100644 --- a/content/docs/references/automation/builtin-node-config.mdx +++ b/content/docs/references/automation/builtin-node-config.mdx @@ -77,8 +77,9 @@ this contract describe the same surface from the two sides. The `create_record` / `update_record` `fields` map carries the same value contract since #19938 (`FlowValueSlotSchema`, the "value slots" section): a field value is a CEL value envelope or a literal — a `{token}` template is -refused there since #19939 — and the three maps are the expression ledger's -`value`-role slots. +refused there since #19939 — and those maps are the expression ledger's +`value`-role slots, as are, since #19939, the three maps a node hands to a +callee and a `screen`'s `defaults.*`, field `defaultValue` and `recordId`. Deliberately absent: - `decision` / `script` / `subflow` / `wait` / `connector_action` — the @@ -230,8 +231,8 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr | **objectName** | `string` | optional | Render this object's full create/edit form instead of a flat field list | | **idVariable** | `string` | optional | Object form only: variable bound to the saved record's id — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` | | **mode** | `Enum<'create' \| 'edit'>` | optional (default: `"create"`) | Object form only: create (default) or edit; 'edit' needs a recordId to name its target | -| **recordId** | `string` | optional | Object form only: id of the record to edit (required for mode: 'edit' to be useful) | -| **defaults** | `Record` | optional | Object form only: prefilled values | +| **recordId** | `string \| { dialect: 'cel'; source: string; ast?: any; meta?: object }` | optional | Object form only: id of the record to edit (required for mode: 'edit' to be useful) — a string, or a CEL value envelope `{ dialect: 'cel', source }` evaluated when the screen is served to the id, a non-blank string (anything else, null included, fails the run at the screen); a `{…}` template token is refused | +| **defaults** | `Record` | optional | Object form only: prefilled values keyed by the object's field names — each a CEL value envelope `{ dialect: 'cel', source }` evaluated when the screen is served (the client receives the value, never the envelope), or a literal written as it is; a `{…}` template token is refused | ### Nested Shape: `ScreenConfig.fields[number]` @@ -242,7 +243,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr | **type** | `string` | optional | Input type | | **required** | `boolean` | optional | Whether a value is required to submit | | **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text | -| **defaultValue** | `any` | optional | Prefilled value (interpolates `{token}` templates) | +| **defaultValue** | `any` | optional | Prefilled value: a CEL value envelope `{ dialect: 'cel', source }` evaluated when the screen is served (the client receives the value, never the envelope), or a literal written as it is — a `{…}` template token is refused | | **placeholder** | `string` | optional | Input placeholder text | | **visibleWhen** | `string` | optional | CEL predicate controlling visibility, evaluated client-side | | **min** | `number` | optional | Minimum accepted value (numeric fields); enforced on resume | @@ -264,7 +265,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr | **type** | `string` | optional | Input type | | **required** | `boolean` | optional | Whether a value is required to submit | | **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text | -| **defaultValue** | `any` | optional | Prefilled value (interpolates `{token}` templates) | +| **defaultValue** | `any` | optional | Prefilled value: a CEL value envelope `{ dialect: 'cel', source }` evaluated when the screen is served (the client receives the value, never the envelope), or a literal written as it is — a `{…}` template token is refused | | **placeholder** | `string` | optional | Input placeholder text | | **visibleWhen** | `string` | optional | CEL predicate controlling visibility, evaluated client-side | | **min** | `number` | optional | Minimum accepted value (numeric fields); enforced on resume | From 02c8662f911be9a34dce38bbc69cb8edd23f8021 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 10:52:19 +0000 Subject: [PATCH 6/7] wip(service-automation): fix an engine pin title (#19939 S2) Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- .../service-automation/src/builtin/screen-value-slots.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-automation/src/builtin/screen-value-slots.test.ts b/packages/services/service-automation/src/builtin/screen-value-slots.test.ts index 8b2e58279dc..15115197ce4 100644 --- a/packages/services/service-automation/src/builtin/screen-value-slots.test.ts +++ b/packages/services/service-automation/src/builtin/screen-value-slots.test.ts @@ -100,7 +100,7 @@ const POSITIONS = [ ] as const; describe('1. a `{token}` at a screen\'s value slots is refused at every door', () => { - it.each(POSITIONS)('%s: `registerFlow` refuses it at `config.%s`, naming the envelope and what the client is served', (_what, configOf, at) => { + it.each(POSITIONS)('%s: `registerFlow` refuses it at its path, naming the envelope and what the client is served', (_what, configOf, at) => { let message = ''; try { makeEngine().registerFlow('refused', screenFlow('refused', configOf('{account_id}'))); From f915b87714d0d73d06248114fbefa1c8449fb08e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:54:13 +0000 Subject: [PATCH 7/7] =?UTF-8?q?docs(spec,service-automation):=20the=20Clau?= =?UTF-8?q?se-=E2=91=A1=20line=20reads=20yes=20(narrowing;=20widening)=20?= =?UTF-8?q?=E2=80=94=20recordId=20newly=20accepts=20an=20envelope=20?= =?UTF-8?q?=E2=80=94=20and=20three=20sentences=20the=20screen=20value=20sl?= =?UTF-8?q?ots=20made=20false=20(#19939=20S2,=20patch=20round=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- .changeset/19939-flow-screen-value-slots.md | 10 ++++++---- packages/runtime/src/domains/automation.ts | 2 +- packages/services/service-automation/README.md | 6 +++--- .../spec/src/automation/builtin-node-config.zod.ts | 5 ++++- .../spec/src/automation/flow-text-slot-template.ts | 10 +++++----- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/.changeset/19939-flow-screen-value-slots.md b/.changeset/19939-flow-screen-value-slots.md index 4eee580ccf3..8283df909f5 100644 --- a/.changeset/19939-flow-screen-value-slots.md +++ b/.changeset/19939-flow-screen-value-slots.md @@ -3,13 +3,13 @@ '@objectstack/service-automation': major --- -A `screen` node's three positions that hand a value to the client are value slots now: an object form's `defaults` (each field's prefill), a field's `defaultValue`, and an object form's `recordId` (the record an `edit` form opens). Each value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`, that the executor evaluates in the run's scope before the screen goes on the wire, so the client receives the value it computes, never the envelope; or a literal, served as written. A `{…}` template token there is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling, as in every other value slot. `recordId` gains the envelope arm, and its literal stays a string. +A `screen` node's three positions that hand a value to the client are value slots now: an object form's `defaults` (each field's prefill), a field's `defaultValue`, and an object form's `recordId` (the record an `edit` form opens). Each value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`, that the executor evaluates in the run's scope before the screen goes on the wire, so the client receives the value it computes, never the envelope; or a literal, served as written. A `{…}` template token there is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling, as in every other value slot. `recordId` gains the envelope arm, which it refused before this change, and its literal stays a string. -Clause-②: no (narrowing) +Clause-②: yes (narrowing: a {…} token is refused at screen.defaults.*, a field's defaultValue and screen.recordId; widening: screen.recordId accepts a CEL value envelope it refused, and its published JSON Schema and inferred type gain the envelope arm) -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). +**BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (`recordId` accepts a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. A screen's prefill and its record id are computed values handed to the client. Until now the executor ran each through the single-brace interpolator: a `{token}` resolved there, an envelope written in `defaults` or a `defaultValue` went to the client as the object `{ dialect: 'cel', source: '…' }` it spells, and `recordId` was the text of whatever the token found, so a token that found a record served the id `[object Object]`. @@ -36,7 +36,9 @@ The refusal at each position says what it serves. **Who is affected, measured.** In this repository: the CRM example's lead conversion (`crm_convert_lead_wizard`: 3 `defaults` values in its two object-form steps), the object-form example in the flows guide, and test fixtures; all are migrated in this change. hotcrm's sites at these positions were not measured. -**Still accepted, unchanged.** A CEL value envelope and every literal, a string `recordId` included. The single-brace dialect keeps resolving where it still lives: a `map` or `loop` `collection`, a `filter` value, a `notify` `recipients` entry, an `http` body. The value-slot retirement's earlier changesets on this line list a screen's `defaults` among those positions; this one supersedes that line. +**Newly accepted.** A CEL value envelope at `recordId`, which `z.string()` refused before this change: the published JSON Schema for `recordId` gains the envelope arm (a string, or the envelope object), and so does the inferred `ScreenConfig['recordId']`. + +**Still accepted, unchanged.** A CEL value envelope at `defaults` and at a field's `defaultValue`, and every literal at all three, a string `recordId` included. The single-brace dialect keeps resolving where it still lives: a `map` or `loop` `collection`, a `filter` value, a `notify` `recipients` entry, an `http` body. The value-slot retirement's earlier changesets on this line list a screen's `defaults` among those positions; this one supersedes that line. ### The kit diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 89759ab2330..cf6bb5582ac 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -3195,7 +3195,7 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // wrong gate for this route (it would refuse the end user the flow // paused for) — and left the door authenticated-only as a result. The // residual it named was measured and is real: a `ScreenSpec` carries - // `defaults` / `defaultValue` interpolated against the live flow + // `defaults` / `defaultValue` resolved against the live flow // variables (`builtin/screen-nodes.ts`), so a real screen flow over // `{record.email}` / `{record.phone}` answered those values to ANY // authenticated caller who knew a run id. The ruling of 2026-08-12 diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index 29c0b0cdbc2..dfaeb494a44 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -188,7 +188,7 @@ a CEL value envelope, and text is a `{{ }}` template.** | Start-node `condition` | CEL — bare, no braces | `record.amount > 500` | | Edge `condition` | CEL — bare, no braces | `record.status == 'open'` | | Decision `conditions[].expression` | CEL — bare, no braces | `order_amount > 10000` | -| Value slots — `create_record` / `update_record` `fields`, `assignment` values, and the maps handed to a callee: `subflow` / `map` `input`, `script` `inputs` | CEL value envelope; a plain string is the literal text it spells | `{ dialect: 'cel', source: "'Follow up on ' + record.name" }`, `'open'` | +| Value slots — `create_record` / `update_record` `fields`, `assignment` values, the maps handed to a callee: `subflow` / `map` `input`, `script` `inputs`, and a `screen`'s `defaults`, field `defaultValue` and `recordId`, each evaluated before the screen is served | CEL value envelope; a plain string is the literal text it spells (`recordId`'s literal is the id, a string) | `{ dialect: 'cel', source: "'Follow up on ' + record.name" }`, `'open'` | | Text slots — `notify` `title` / `message`, `screen` `title` / `description`, `end` `message` | Template — `{{ }}` holes (ADR-0032 §3; a single-brace token is refused) | `'Deal won: {{ record.name }}'`, `'{{ record.amount \| currency }}'` | A value slot refuses every `{…}` template token — `registerFlow()` and @@ -208,8 +208,8 @@ Inside an object or list value, and in the legacy `assignment` shapes, an envelope is data: build the whole value as one envelope (a CEL map or list literal, its mixed values wrapped in `dyn(…)`), or move the assignment into the `assignments` map. Other value-like positions — a `filter`, `recipients`, an -`http` payload, a screen's `defaults` — still read the single-brace dialect -(`{record.id}`). +`http` payload, a `map` or `loop` `collection` — still read the single-brace +dialect (`{record.id}`). A `subflow` or `map` `input`, and a `script`'s `inputs`, hand their values to a callee: each envelope is evaluated in the calling flow's scope (on a `map`, once diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index 2d42ab86e82..30cf522a679 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -862,7 +862,10 @@ const SCREEN_RECORD_ID_REFUSAL = * `null` included, rather than serving an `edit` form with no record to * open (`service-automation`'s `screen-nodes.ts`); * - anything else is refused ({@link SCREEN_RECORD_ID_REFUSAL}); a malformed - * envelope leads with {@link VALUE_ENVELOPE_REFUSAL}, as in every value slot. + * envelope leads with {@link VALUE_ENVELOPE_REFUSAL}, as in every value + * slot, at both build doors (their envelope pass). This union's own parse + * leads with it too, except for an envelope the envelope arm parses and then + * refuses (a blank `source`): the union reports that arm's own sentence. * * A union rather than {@link FlowValueSlotSchema}'s open `unknown`: the * literal arm is narrower here (an id is a string, as it always was), and the diff --git a/packages/spec/src/automation/flow-text-slot-template.ts b/packages/spec/src/automation/flow-text-slot-template.ts index 1014ad60182..c9c1e699c91 100644 --- a/packages/spec/src/automation/flow-text-slot-template.ts +++ b/packages/spec/src/automation/flow-text-slot-template.ts @@ -168,11 +168,11 @@ export interface FlowNodeTextSlot { * one text renderer). Every OTHER string the flow interpolator reads keeps its * single-brace dialect: a value-like position whose single token hands its * resolved value over with its TYPE (`recipients`, `actionUrl`, `sourceId`, - * `payload`, `templateData`, a screen's `recordId` / `defaults` / field - * `defaultValue`, `http`, the `loop` / `map` `collection`, a `filter`) is not - * a text template, and the value slots (`fields.*`, `assignments.*`, and the - * callee maps `subflow.input.*`, `map.input.*`, `script.inputs.*`) are CEL's - * (#19939). + * `payload`, `templateData`, `http`, the `loop` / `map` `collection`, a + * `filter`) is not a text template, and the value slots (`fields.*`, + * `assignments.*`, the callee maps `subflow.input.*`, `map.input.*`, + * `script.inputs.*`, and a screen's `defaults.*`, field `defaultValue` and + * `recordId`) are CEL's (#19939). */ export const FLOW_NODE_TEXT_SLOTS: readonly FlowNodeTextSlot[] = [ { nodeType: 'notify', key: 'title', label: 'notify title' },