Skip to content

Commit 12b9daf

Browse files
feat(spec,service-automation)!: a screen's defaults, a field's defaultValue and recordId are value slots — a CEL envelope evaluated before the screen is served, the {…} token refused (#19939 pass 4, S2) (#22832)
Part of #19939 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) Pass 4, stage S2 of #19939 (stage plan `6096263424`, claim `6107940231`), under ruling D on #11182 and ADR-0032 Decisions 2 and 3. A `screen` node's three positions that hand a value to the client become value slots: an object form's `defaults.*`, a field's `defaultValue`, and an object form's `recordId`. Each takes a CEL value envelope, which the executor evaluates in the run's scope before the screen goes on the wire, or a literal. A single-brace `{…}` token is refused at all three. #19939 stays open for stages S3 to S7, so this PR uses `Part of`. ## What lands **The contract and the ledger (`@objectstack/spec`).** - `ScreenConfigSchema.defaults` takes `FlowValueSlotSchema` per key, and a field's `defaultValue` takes `FlowValueSlotSchema`. Each `describe()` is restated. - `ScreenConfigSchema.recordId` gains the envelope arm: a string, or a CEL value envelope (a union, so the published JSON Schema and the inferred type keep the string literal). A `{token}` in the string is refused by the one value-slot judge. Any other value (`42`, `null`, a list) is refused with a sentence naming what the id is for. - `FLOW_NODE_EXPRESSION_PATHS` appends three `value` rows: `screen.defaults.*`, `screen.fields[].defaultValue`, `screen.recordId`. `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries `ScreenConfigSchema`, as it carries `MapConfigSchema` since S1, because the descriptor offers these positions with no marker. The ratchet finds the three markers on the spec Zod channel and `fields[].visibleWhen` on the descriptor channel alone. - So the one judge (`flowNodeValueTemplateRefusals`) refuses a token at these positions at `registerFlow` and `objectstack validate`, and the envelope pass validates an envelope there. No second judge was added. - **The screen sentence.** At a top-level value, the refusal ends with what the client is served where the template served nothing. The table is keyed by the ledger entry, like S1's callee sentence. - `dropped-refinements.baseline.json` declares the three new dropped refinements (`defaults.valueType`, `recordId`, `defaultValue`). The header total moves 705 to 708. **The executor (`@objectstack/service-automation`).** - `screen-nodes.ts` resolves the three readers through S1's value-slot resolver. `defaults` goes through `resolveValueSlotMap`. `defaultValue` and `recordId` go through the new single-value `resolveValueSlot` in `value-slot-map.ts`, which the map resolver now calls per key, so the dispatch by shape is spelled once. - An envelope goes to `AutomationEngine.evaluateValueEnvelope` in the run's scope and context. A literal is served as written. There is no `??` fallback to the interpolator. - `recordId` must evaluate to a non-blank string. Anything else fails the node there, naming its source; it is never `String(value)`. - A screen that passes through without pausing goes on no wire and evaluates nothing, as before. - The descriptor's three descriptions, which said "Interpolates {var} references", are restated. **ADR-0087.** The step-18 D3 entry `18.flow-value-slot-template-dialect-refused` is amended: - the surface widens to the three screen positions; - `replacement` and `reason` say what each position serves where the template served nothing; - `acceptanceCriteria` gains the screen check, and also the callee caveat the S1 ACCEPT carried to this stage. There is no D2: every whole-path spelling answers differently for an absent value. `registry.ts` is generated at build from the entries and gitignored at this base (`.gitignore:68`), so no regenerated registry file is in the diff. The typecheck's `gen:migration-registry` reports it current (422 semantic entries). The changeset marker is `not-required (already-registered flow-value-slot-template-dialect-refused)`, and `check-adr-0087-registration` accepts it. **Sites migrated**, re-derived at HEAD with the stage-0 census instrument. Positive control: the 3 tokened `screen.defaults` values and the 1 tokened `screen.recordId` value it found at base read 0 tokened at HEAD. - `examples/app-crm` `crm_convert_lead_wizard`: 3 `defaults` values. `company` is optional on a lead, so it is guarded. - `content/docs/automation/flows.mdx`: the object-form example, the value-slot list, and a new paragraph on what each screen position serves. - The `text-slot-template.test.ts` `recordId` fixture, and test fixtures in spec and lint. ## Measured: what each position serves Every reading is through the shipped `AutomationEngine`, on the screen the paused run returns (`screen-value-slots.test.ts`). | position | envelope evaluates to a value | absent, unguarded | guarded `has(…) ? … : null` | the template, before | |:--|:--|:--|:--|:--| | `defaults.*` | the value: a record as a map, a list as a list | the run fails at the screen, naming the source | `null`, served as a key with value `null` | nothing: the key left the wire | | `fields[].defaultValue` | the value | the run fails at the screen | `null` | nothing | | `recordId` | the string id | the run fails at the screen | the run fails: `null` is not a record id | `String(resolved)`: nothing, or `[object Object]` for a record | What the client does with it, read from objectui at the pinned `.objectui-sha` `20c6d351ad`: - `initialRecord.ts` keeps a `null` prefill member as a value, and `seedCreateValues` puts the caller layer over the object schema's declared defaults. So `defaults: { k: null }` opens the field blank over the object field's `defaultValue`, which applied when the key was absent. The refusal and the D3 entry say: write that default in the guard. - `ScreenView.initialScreenValues` seeds a field from any `defaultValue` that is not `undefined`, `null` included, and `resumeWith(values)` submits it. - `ObjectForm` with `mode: 'edit'` and no `recordId` reads no record, and its save throws "Invalid form mode or missing record ID". This is why `recordId` refuses a `null` rather than serving such a form. The refusal tells the author to route around the screen with a `decision`. ## Premises checked against `f313fbcc99` - **Assumption 1 is false for `recordId`.** At base an envelope there was refused (`z.string()`): the parse refused it with `invalid_type`, and the build doors with `node-config-refused-by-contract@recordId`. It was not served verbatim. It held for `defaults` and `defaultValue`. Measured by a probe through `flowNodeConfigRefusals`, `flowNodeValueTemplateRefusals` and `ScreenConfigSchema`. - The reader lines moved since the census: `ref()` was at `:234` and used at `:247`, `defaults` was at `:250`, and `defaultValue` at `:313`. - **Out of the claim's file surface, and why.** - `flow-node-config-refusals.ts`. The build doors' value arm skipped every issue at or inside a `value` slot. That would have stopped refusing `recordId: 42` at `FlowSchema.parse`, `registerFlow` and `objectstack validate`: a widening of the doors, because no other judge reads a value slot's literal type. It now skips a `value` slot only where the slot holds an envelope (the envelope pass owns it); a token is skipped by the existing carve-out. Every other value slot's literal arm refuses nothing but a token, so no other verdict moves. - In the same file, `insideValueSlot` matched only map paths. A malformed envelope at `fields[0].defaultValue` was also reported as `node-config-key-required-by-rule` at `fields[0].defaultValue.source`. It now matches `[]` segments too. - Both are pinned (`flow-builtin-node-config-values.test.ts`), and ablations 4 and 5 below turn them red. - `schemaless-node-config.zod.ts`: `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` gains `screen` (the S1 precedent for `map`). - `value-slot-map.ts`: `resolveValueSlot`. It is not exported from the package entry. ## Pins, and the ablations that turn them red - `screen-value-slots.test.ts` holds 25 engine pins: - refusal at `registerFlow`, at the executor's guard and at the envelope pass, per position; - evaluation on the served spec, with no `dialect` on the wire; - absent and `null` per position; - `recordId` refusing `null`, a map, a list and a blank string. - In spec: contract pins (`builtin-node-config.test.ts`), ledger pins, screen-sentence pins (`flow-value-slot-template.test.ts`), the build doors' value arm, and the text-slot test re-judged in place. `config-expression-ledger.test.ts` covers the ratchet. - The lint hints flip by asking the judge, with no lint source change: 6 pins in `lint-flow-patterns.test.ts`. - **Ablations**, each through `scripts/ablation-replace.mjs`: the anchor hit once, the blob changed, and the restore was proven with blob equal to HEAD and `git diff HEAD` empty. Each was run at `528f5ca363`. 1. `resolveValueSlot` never evaluates (`false && isExpressionEnvelopeShaped(value)`): 11 of the 25 engine pins went red, the evaluation and absent/`null` pins. The refusal pins stayed green, as expected. 2. `recordId` falls back to `String(id)`: the 5 `recordId` refusal pins went red. 3. The `screen.recordId` ledger row renamed: 10 spec pins in 3 files went red. 4. The value-arm carve-out back to whole slots: `screen {"recordId":42} is refused at recordId` went red. 5. `insideValueSlot` back to map-only matching: the "slot another judge owns" control went red. It showed the `node-config-key-required-by-rule` double report. 6. The screen-sentence table emptied: 6 judge pins went red. - Not ablated at dist level: the service-automation and lint pins read `@objectstack/spec`'s `dist`. Ablations 3 and 6 turned the same functions red at source. ## Measurements - **Gates** at `d693848a79`. `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 115 commands from the 23 changed paths. All 115 exited 0. `--ran` with the recorded exit codes reads 115 run, 0 NOT-MEASURED (a derived zero). - This includes `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:doc-authoring`, `check:i18n-coverage`, `check:examples-live-imports`, `check:nul-bytes`, `check:type-check-debt`, and the spec gates `check:generated`, `check:api-surface`, `check:export-origins`, `check:docs`, `check:spec-changes`, `check:upgrade-guide`, `check:authorable-surface` and `check:skill-examples`. - On the first run, at `528f5ca363`, three gates exited 3 (`PREREQUISITE NOT MET`): `check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n-coverage`. They passed once the packages they read were built, and passed again in the full second run. - The dispatch also named `check:future-spec-major`, which exited 0. There is no `check:migration-registry` script at this base. - **Tests:** - service-automation full suite at `528f5ca363`: 186 files, 2397 tests pass; - spec local project, 648 files, 19370 pass; spec repo project, 55 files, 971 pass; - lint full suite, 137 files, 6422 pass; - example-crm, 6 files, 46 pass. - At `d693848a79`, after the second merge (which touched only `core`, `mcp` and `metadata-protocol`): the 5 touched service-automation files (109 tests), spec `src/automation` (1406) and the two touched lint files (227) pass. - Typecheck is green for spec, service-automation, lint and example-crm. - **The CRM example through every judge.** `validateStackExpressions` reports 0 issues, the flow lint's value-slot hints 0, and `registerFlow` accepts `crm_convert_lead_wizard`. Its screens serve `{"name":"Acme"}` for a lead with a company and `{"name":null}` for one without. - **Semver.** `@objectstack/spec` and `@objectstack/service-automation` are `major` (pre mode `next`, re-read at push). The `Clause-②` line reads `yes (narrowing: …; widening: …)`, on this body and in the changeset (patch round 1, after the contract review `6109203893`): - the narrowing: a `{…}` token is refused at `screen.defaults.*`, a field's `defaultValue` and `screen.recordId`; - the widening: `screen.recordId` accepts a CEL value envelope that `z.string()` refused, and its published JSON Schema (`anyOf: [string, object]`) and inferred type gain the arm: `ScreenConfig['recordId']` infers `string | AssignmentExpressionValue` where it inferred `string`. - No `api-surface/**` or `export-origins/**` file moved. `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` gains `screen`, which widens `LedgerDeclaredNodeType` and `ReconciledNodeConfigType`; that is data in existing exports, as S1's `map` was. - **Size:** 23 files, +1185 / −145 against `main` (1330 changed lines). ## Acceptance notes - **The executor's parse of a blank-`source` envelope at `recordId`** returns the envelope arm's own sentence, without the `VALUE_ENVELOPE_REFUSAL` lead: the union reports that arm's issue directly. `registerFlow`'s envelope pass, which runs first, leads with it. - **A literal `recordId: ''`** still parses, as it did. Only an evaluated id must be non-blank. - **The Studio form.** The descriptor keeps `recordId` as `type: 'string'` and `defaults` as an open key-value map, so the designer cannot author an envelope at these positions. Carrier: the objectui companion card objectstack-ai/objectui#12101, which is for the seat to extend. Sites there: - `flow-node-config.ts:856`, whose `defaults` help text names `{account_id}`; - `previews/screen-spec.ts`, which interpolates `defaultValue` and `defaults` with the single brace. - `validate-flow-template-paths` still path-checks single-brace tokens at declared value slots, which doubles the judge's finding. This is the note passes 1 to 3 carried; S7 removes it. - `flows.mdx` named a `subflow` input as a single-brace position in two places, stale since S1. Both are fixed here, in the file this PR already edits: the surrounding examples are now `notify` recipients and `http` bodies. - **Skills.** `skills/**` carries no text about these three positions (measured, zero hits), so this stage owes no #22585 pointer. - **hotcrm:** NOT MEASURED, as for the earlier stages. --- _Generated by [Claude Code](https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8558668 commit 12b9daf

26 files changed

Lines changed: 1199 additions & 154 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/service-automation': major
4+
---
5+
6+
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.
7+
8+
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)
9+
10+
<!-- adr-0087: not-required (already-registered flow-value-slot-template-dialect-refused) The value-slot retirement's step-18 D3 entry, registered on this line before this change, is amended in this diff: its surface widens to a screen's defaults map, a field's defaultValue and recordId, and its replacement, reason and acceptance criteria say what each serves where the template served nothing. No new D3 entry and no D2 conversion: every whole-path spelling answers differently for an absent value. -->
11+
12+
**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.*`).
13+
14+
**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]`.
15+
16+
**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`:
17+
18+
- 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'`.
19+
- A field's `defaultValue` seeds the field with `null`.
20+
- `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.
21+
22+
The refusal at each position says what it serves.
23+
24+
## FROM → TO
25+
26+
| you wrote | write instead | what changes |
27+
|:--|:--|:--|
28+
| `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` |
29+
| `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 |
30+
| `defaults: { parent: '{rec}' }` | `defaults: { parent: { dialect: 'cel', source: 'rec' } }` | the record is served as a map, a list as a list |
31+
| `fields: [{ name: 'email', defaultValue: '{lead.email}' }]` | `fields: [{ name: 'email', defaultValue: { dialect: 'cel', source: 'lead.email' } }]` | guarded, it prefills `null` |
32+
| `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 |
33+
| `recordId: 'acc_1'` | unchanged | a string literal is the id |
34+
35+
**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.**
36+
37+
**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.
38+
39+
**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']`.
40+
41+
**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.
42+
43+
### The kit
44+
45+
- **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`).
46+
- **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.
47+
- **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.
48+
- **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.

‎content/docs/automation/flows.mdx‎

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,12 @@ The same rules hold for the field values of `create_record` / `update_record`
230230
`map` node's `input` (the child flow's input variables — on a `map`, evaluated
231231
once per item with the item variable bound) and a `script` node's `inputs` (the
232232
registered function's `input`). Those maps — `assignments`, `fields`, `input`,
233-
`inputs` — are the flow's **value slots**, and each value takes a CEL value
234-
envelope or a literal. Only a slot's top-level value is an expression — an
235-
object nested inside a JSON value or an array is data, whatever keys it carries.
233+
`inputs` — are the flow's **value slots**, and so are a `screen` node's three
234+
positions that hand a value to the client: an object form's `defaults` (each
235+
field's prefill), a field's `defaultValue`, and an object form's `recordId`.
236+
Each value takes a CEL value envelope or a literal. Only a slot's top-level
237+
value is an expression — an object nested inside a JSON value or an array is
238+
data, whatever keys it carries.
236239

237240
<Callout type="info" title="Where a malformed envelope is refused">
238241

@@ -295,9 +298,29 @@ the key out of `input` where the value is never meant to be supplied. A `script`
295298
function is handed `null` there, where the template handed `undefined`. The
296299
refusal at those positions says so.
297300

301+
**A screen's prefill and record id: evaluated before the screen is served.** On
302+
a `screen` node, each envelope in `defaults`, a field's `defaultValue` and
303+
`recordId` is evaluated in the run's scope before the screen goes to the client,
304+
so the client receives the value — a record as a map, a list as a list — never
305+
the envelope. Where a whole token resolved to nothing, the template served
306+
nothing there; the guarded form serves `null`:
307+
308+
- in an object form's `defaults`, the form keeps a `null` prefill as a value and
309+
opens the field blank — over the object field's own `defaultValue`, which it
310+
applied when the prefill left the field out. Write that default in the guard
311+
(`has(vars.x) ? vars.x : 'prospecting'`) to keep it;
312+
- a field's `defaultValue` seeds the field with `null`;
313+
- `recordId` names the record an `edit` form opens, so its envelope must
314+
evaluate to the record's id, a non-blank string: anything else, `null`
315+
included, **fails the run at the screen**. The template served such a screen
316+
with no record id, and its `edit` form opened with no record and could not
317+
save. Where the record may be absent, route around the screen with a
318+
`decision` on the value instead of guarding the id.
319+
298320
**Where an envelope is data, the remedy names none there.** An envelope
299-
evaluates only as the top-level value of a value slot's map — `fields`,
300-
`assignments`, or a callee's `input` / `inputs`. Inside an object or list value, and in the two legacy `assignment` shapes
321+
evaluates only as the top-level value of a value slot — `fields`,
322+
`assignments`, a callee's `input` / `inputs`, or a screen's `defaults`,
323+
`defaultValue` or `recordId`. Inside an object or list value, and in the two legacy `assignment` shapes
301324
(an `assignments: [{ variable, value }]` array, variables as the config's own
302325
keys), it is stored as the object it spells — so the refusal there names what
303326
does evaluate:
@@ -645,9 +668,9 @@ bound to `config.idVariable` so a later step can reference it.
645668
config: {
646669
objectName: 'opportunity', // render this object's full form
647670
mode: 'create', // 'create' (default) | 'edit'
648-
// recordId: '{account_id}', // required for mode: 'edit'
649-
defaults: { // pre-filled values (interpolated)
650-
account: '{account_id}',
671+
// recordId: { dialect: 'cel', source: 'opportunity_id' }, // required for mode: 'edit'
672+
defaults: { // pre-filled values: a CEL envelope or a literal each
673+
account: { dialect: 'cel', source: 'account_id' },
651674
stage: 'prospecting',
652675
},
653676
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
20272050
value envelope. **Text** slots — a `notify` title or message, a screen title or
20282051
description, a refusing `end` node's message — are `{{ }}` templates (see
20292052
[text slots](#text-slots-read-double-brace-holes)). Single braces remain only on the
2030-
value-like positions that hand a resolved value over (`recipients`,
2031-
`sourceId`, a `subflow` input, an `http` body, …).
2053+
value-like positions that hand a resolved value over and that the retirement
2054+
has not reached yet (`recipients`, `sourceId`, an `http` body, …).
20322055

20332056
| Where | Dialect | Write it like | Bindings |
20342057
|:---|:---|:---|:---|
@@ -2069,7 +2092,7 @@ and is read as `vars["current_user"]` / `vars["vars"]`.
20692092
braces.
20702093
3. **A `{…}` template in a value slot** — `total: '{round(x * 100) / 100}'`,
20712094
`owner: '{record.owner}'` — is refused with the CEL spelling to write
2072-
instead. In a single-brace position (an `http` body, a `subflow` input), an
2095+
instead. In a single-brace position (an `http` body, a `notify` recipient), an
20732096
unsupported function in braces — `'{ROUND(x, 2)}'`, `'{Math.round(x)}'`,
20742097
`'{(x).toFixed(2)}'` — fails the node with a **named error** listing the
20752098
supported set and, where one is close, the spelling you meant. Before #11060

‎content/docs/references/automation/builtin-node-config.mdx‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,9 @@ this contract describe the same surface from the two sides.
7777
The `create_record` / `update_record` `fields` map carries the same value
7878
contract since #19938 (`FlowValueSlotSchema`, the "value slots" section):
7979
a field value is a CEL value envelope or a literal — a `{token}` template is
80-
refused there since #19939 — and the three maps are the expression ledger's
81-
`value`-role slots.
80+
refused there since #19939 — and those maps are the expression ledger's
81+
`value`-role slots, as are, since #19939, the three maps a node hands to a
82+
callee and a `screen`'s `defaults.*`, field `defaultValue` and `recordId`.
8283

8384
Deliberately absent:
8485
- `decision` / `script` / `subflow` / `wait` / `connector_action` — the
@@ -230,8 +231,8 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
230231
| **objectName** | `string` | optional | Render this object's full create/edit form instead of a flat field list |
231232
| **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 }}` |
232233
| **mode** | `Enum<'create' \| 'edit'>` | optional (default: `"create"`) | Object form only: create (default) or edit; 'edit' needs a recordId to name its target |
233-
| **recordId** | `string` | optional | Object form only: id of the record to edit (required for mode: 'edit' to be useful) |
234-
| **defaults** | `Record<string, any>` | optional | Object form only: prefilled values |
234+
| **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 |
235+
| **defaults** | `Record<string, any>` | 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 |
235236

236237
### Nested Shape: `ScreenConfig.fields[number]`
237238

@@ -242,7 +243,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
242243
| **type** | `string` | optional | Input type |
243244
| **required** | `boolean` | optional | Whether a value is required to submit |
244245
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
245-
| **defaultValue** | `any` | optional | Prefilled value (interpolates `{token}` templates) |
246+
| **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 |
246247
| **placeholder** | `string` | optional | Input placeholder text |
247248
| **visibleWhen** | `string` | optional | CEL predicate controlling visibility, evaluated client-side |
248249
| **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
264265
| **type** | `string` | optional | Input type |
265266
| **required** | `boolean` | optional | Whether a value is required to submit |
266267
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
267-
| **defaultValue** | `any` | optional | Prefilled value (interpolates `{token}` templates) |
268+
| **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 |
268269
| **placeholder** | `string` | optional | Input placeholder text |
269270
| **visibleWhen** | `string` | optional | CEL predicate controlling visibility, evaluated client-side |
270271
| **min** | `number` | optional | Minimum accepted value (numeric fields); enforced on resume |

‎examples/app-crm/src/flows/convert-lead.flow.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,13 @@ export const ConvertLeadScreenFlow = defineFlow({
107107
idVariable: 'account_id',
108108
title: 'Step 1 of 2 · Customer',
109109
description: 'Review and complete the customer record carried over from the lead.',
110+
// Each prefill is a value slot: a CEL value envelope, evaluated before
111+
// the screen is served, so the form receives the value. `lead_record`
112+
// is bound by `get_lead` (the decision above reads it), but `company`
113+
// is optional on a lead, so its absence is guarded: the form opens
114+
// the name blank, as it did when the template found nothing.
110115
defaults: {
111-
name: '{lead_record.company}',
116+
name: { dialect: 'cel', source: 'has(lead_record.company) ? lead_record.company : null' },
112117
},
113118
},
114119
},
@@ -127,10 +132,13 @@ export const ConvertLeadScreenFlow = defineFlow({
127132
idVariable: 'opportunity_id',
128133
title: 'Step 2 of 2 · Opportunity',
129134
description: 'Add the opportunity and its product line items. Saved together in one transaction.',
135+
// `name` is required on a lead, and `account_id` is bound by the
136+
// Customer step's `idVariable` before this step runs, so neither needs
137+
// a `has()` guard.
130138
defaults: {
131-
name: '{lead_record.name}',
132-
account: '{account_id}',
133-
stage: 'qualification',
139+
name: { dialect: 'cel', source: 'lead_record.name' },
140+
account: { dialect: 'cel', source: 'account_id' },
141+
stage: 'qualification',
134142
},
135143
},
136144
},

0 commit comments

Comments
 (0)