Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/19939-flow-screen-value-slots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
'@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, which it refused before this change, and its literal stays a string.

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)

<!-- 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. -->

**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]`.

**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.

**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

- **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.
45 changes: 34 additions & 11 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
|:---|:---|:---|:---|
Expand Down Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, any>` | 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<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 |

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

Expand All @@ -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 |
Expand All @@ -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 |
Expand Down
16 changes: 12 additions & 4 deletions examples/app-crm/src/flows/convert-lead.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
},
},
},
Expand All @@ -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',
},
},
},
Expand Down
Loading
Loading