Skip to content

Commit f66fdc7

Browse files
feat(spec,service-automation)!: a subflow or map input and a script's inputs are value slots — a CEL envelope per key, the {…} token refused (#19939 pass 4, S1) (#22715)
Part of #19939 Clause-②: no (narrowing) Pass 4, stage S1 of #19939 (stage plan `6096263424`, claim `6096277270`), under ruling D on #11182 and ADR-0032 Decisions 2 and 3. The maps a flow node hands to a callee become value slots: `subflow.input.*`, `map.input.*` and `script.inputs.*`. Each value is a CEL value envelope that the executor evaluates in the calling flow's scope, or a literal. A single-brace `{…}` token there is refused. #19939 stays open for stages S2 to S7, so this PR uses `Part of`. ## What lands **The ledger and the contracts (`@objectstack/spec`).** - `FLOW_NODE_EXPRESSION_PATHS` gains three `value` rows: `subflow.input.*`, `map.input.*` and `script.inputs.*`. Because the value-slot judge (`flowNodeValueTemplateRefusals`) walks the ledger, it refuses a token at these positions at `registerFlow` and at `objectstack validate` with no second judge. The lint hints `flow-bare-dollar-reference` and `flow-double-brace-interpolation` ask that same judge, so they flip at these positions with no lint source change. - `SubflowConfigSchema.input`, `ScriptConfigSchema.inputs` and `MapConfigSchema.input` take `FlowValueSlotSchema` per value. The executor's contract parse therefore refuses a token as a guard. `map` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, as the CRUD pair did, because its descriptor declares `input` as `additionalProperties: true` with no marker. `map.collection` keeps its `flow-template` slot on the descriptor channel, so no slot is declared twice. - `dropped-refinements.baseline.json` declares the three new dropped value refinements (`input.valueType`, `inputs.valueType`), and its header totals follow the body. **The callee sentence (the judge).** A whole token that resolved to nothing handed the callee nothing. A child flow then seeded the variable from its `defaultValue` (`seedDeclaredVariables` applies the default only to `undefined`). The guarded form the remedy names hands `null`. A supplied `null` wins over the default. At a top-level value of a callee map, the refusal now ends with a sentence that names the child variable and the two ways to keep the default: write it in the guard's `null` branch, or leave the key out. For a `script`, it says the function is handed `null` where it was handed `undefined`. A `$User` path that never resolved gets the variant "leaving the key out keeps exactly that". The positions come from the ledger entry, inside the judge module. No new export was added. **The executor (`@objectstack/service-automation`).** One per-key resolver, `resolveValueSlotMap` (`builtin/value-slot-map.ts`), is generalised from the CRUD `resolveFieldValues`. An envelope goes through `AutomationEngine.evaluateValueEnvelope` in the parent's scope and run context. A literal is handed on as before. The subflow, map (once per item, with the item variable bound) and script executors call it, and so does the CRUD `fields` map. The dispatch is by shape. No `??` fallback to the interpolator was added. **ADR-0087.** The step-18 D3 entry `18.flow-value-slot-template-dialect-refused` is amended: its surface widens to the three maps, and its replacement and reason gain the callee sentence. `registry.ts` is regenerated. There is no D2 entry, because every whole-path spelling answers differently for an absent value. The changeset marker is `not-required (already-registered …)`, and `check-adr-0087-registration` accepts it. **Sites migrated.** All of these are re-derived at HEAD with the stage-0 census instrument: - showcase: 5 values in 3 nodes; - todo: the 3 `computeNextTaskDueDate` inputs, each guarded because each field may be absent and the function reads `null` as it read the empty value; - `flows.mdx`, `runtime-services/examples.mdx` and the service-automation README; - 2 dogfood fixtures, and the service-automation test fixtures (map 8, script 3, subflow 2). What remains at these positions: the 5 historical fixtures inside `conversions/registry.ts`, which are conversion inputs and outputs, and the deliberate refusal fixtures in the new pins. A positive control was measured: the census rows that carried tokens at base now read as envelopes. ## Pins and the ablations that turn them red - `callee-input-value-slots.test.ts` runs 16 pins through the shipped `AutomationEngine`: - refusal at `registerFlow` and at the executor's guard, per position; - evaluation: a list stays a list, a record stays a record, per item on a `map`, and as `input` on a `script`; - a fault names its source; - the callee default: the template handed `undefined` and the default applied; the guarded form hands `null`, which wins; writing the default in the guard keeps it, and so does leaving the key out. - Judge, ledger and contract pins in `@objectstack/spec`, ratchet coverage in `config-expression-ledger.test.ts`, and the hint flips in `lint-flow-patterns.test.ts`. - **Ablations**, through `scripts/ablation-replace.mjs` (anchor hit, blob changed, restore proven equal to HEAD with `git diff HEAD` empty): 1. The resolver hands envelopes to `interpolate` (at `3d832770e7`): 8 of the 16 engine pins went red. These were the evaluation and callee-default pins, and the refusal pins stayed green as expected. 2. The callee-slot table emptied (at `9387cf70ce`): the 6 callee-sentence pins went red. 3. The `subflow.input.*` ledger row renamed (at `9387cf70ce`): 12 judge and ledger pins went red. - Not ablated: the service-automation registration pins read `@objectstack/spec`'s `dist`, and a dist-level ablation was not run. They call the same judge function that ablations 2 and 3 turned red at source. ## Measurements - **Gates** at `9387cf70ce`: `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 120 commands. All 120 exited 0, and `--ran` with recorded exit codes reports 120 run, 0 NOT-MEASURED (a derived zero). This includes `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, and the spec gates `check:generated`, `check:api-surface`, `check:export-origins`, `check:migration-registry`, `check:docs`, `check:skill-examples` and `check:liveness`. - **Tests after the second merge of `main`** (at `9387cf70ce`; the incoming commits touched no file under `spec/src/automation`, `service-automation` or `lint`): - service-automation: 185 files, 2368 tests pass; - spec `src/automation`, `src/migrations` and `scripts/dropped-refinements.test.ts`: 41 files, 1518 pass; - lint `lint-flow-patterns` and `validate-expressions*`: 8 files, 780 pass; - metadata-protocol `reference-sites`: 22 pass. - **Tests at `7d723c9fc1`/`3d832770e7`**: - spec full suite: 20159 pass, plus one fail on the dropped-refinements header totals, which `3d832770e7` corrects; that test file then passed 27 of 27; - lint full: 135 files, 6240 pass; - typecheck green for spec, service-automation and lint; - example-todo typecheck and 238 tests, example-showcase typecheck and 408 tests; - dogfood typecheck, plus the two touched dogfood tests (8 pass); - `objectstack validate` on both examples exits 0 with no value-slot finding. - **Semver**: `@objectstack/spec` major, `@objectstack/service-automation` major (pre mode `next`). No lint source line changed, so there is no lint entry. No `api-surface/**` or `export-origins/**` file moved. The exported ledger constants gain rows: `FLOW_NODE_EXPRESSION_PATHS` +3, and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` + `map`, which widens the `LedgerDeclaredNodeType` union. That is data in existing exports, not a new export, so the line reads `no (narrowing)`. Flagged here for the seat. - **Size**: 38 files, +1099 / −160 against `main`. ## Acceptance notes - The executor's contract parse judges a value with no position, so its refusal names the base remedy without the callee sentence. The two build doors carry the sentence, and this boundary is pinned. - `packages/lint/src/lint-flow-patterns.ts` (the module comment near `:659`) still lists `subflow.input` among the single-brace positions and calls the date macros kept. It is an internal comment and is not published. Carrier: S7, which deletes that arm. - `engine.ts`'s `evaluateValueEnvelope` docblock lists only the assignment and CRUD maps among its callers. The list is now incomplete but not false. Carrier: S3, which edits `engine.ts`. - `predicateSlotRefusal`'s message names the assignment and CRUD maps as the value-role spelling. The list is incomplete but not false. - `examples/app-todo/src/functions/task.functions.ts` still says no flow node evaluates a value expression. That has been stale since the value slots landed, before this pass. - `validate-flow-template-paths` still path-checks single-brace tokens at declared value slots, which doubles a finding the judge already makes. This is the same note passes 1 to 3 carried; S7 removes it. - The published skill (`skills/objectstack-automation/SKILL.md:268`, "inputs are interpolated") is carried by #22585. The objectui designer (`flow-node-config.ts` script inputs and subflow input) is carried by the companion card. hotcrm's sites at these positions were not measured and stay on the seat's ledger. --- _Generated by [Claude Code](https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b32ee3e commit f66fdc7

38 files changed

Lines changed: 1099 additions & 160 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/service-automation': major
4+
---
5+
6+
The maps a flow node hands to a CALLEE are value slots now: a `subflow` node's `input`, a `map` node's `input` (evaluated once per item) and a `script` node's `inputs`. Each value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`, evaluated in the calling flow's scope and handed over as the value it computes, or a literal written as it is. 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.
7+
8+
Clause-②: no (narrowing)
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 the three callee maps, and its replacement and reason gain what a guarded form's null does to a child flow's defaultValue. 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, 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. These three maps are computed values: each one becomes the callee's input. Until now each executor handed its map to the single-brace interpolator, so a `{token}` resolved there, and a CEL envelope written there reached the callee as the object `{ dialect: 'cel', source: '…' }` it spells, with the run reporting success.
15+
16+
**What changes for a value that may be absent.** Where a whole token resolved to nothing, the template handed the callee nothing. A child flow then seeded its input variable from the variable's `defaultValue`. Under CEL an absent variable or key fails the run, and the guarded form `has(vars.x) ? vars.x : null` hands `null`. A `null` the caller supplies is a value: it wins over the child's `defaultValue`. To keep the default for an absent value, write it in the guard, `has(vars.x) ? vars.x : 'standard'`. Leave the key out where the value is never meant to be supplied. A `script` function is handed `null` where the template handed `undefined`. The refusal at those positions says this.
17+
18+
## FROM → TO
19+
20+
| you wrote | write instead | what changes |
21+
|:--|:--|:--|
22+
| `input: { ownerId: '{record.owner}' }` on a `subflow` | `input: { ownerId: { dialect: 'cel', source: 'record.owner' } }` | an absent `owner` fails the run; guarded, it hands `null`, which wins over the child's `defaultValue` |
23+
| `input: { ownerId: '{x}' }` where the child's default should apply when `x` is absent | `input: { ownerId: { dialect: 'cel', source: "has(vars.x) ? vars.x : 'standard'" } }` | the default is written in the guard, because a supplied `null` is not absent |
24+
| `input: { row: '{item}' }` on a `map` | `input: { row: { dialect: 'cel', source: 'item' } }` | evaluated once per item, with the item variable bound; a list or a record is handed over with its type |
25+
| `inputs: { lines: '{lines}' }` on a `script` | `inputs: { lines: { dialect: 'cel', source: 'lines' } }` | the function receives the list; guarded, an absent value is `null` where it was `undefined` |
26+
| `input: { message: 'Task "{record.title}" is done.' }` | `input: { message: { dialect: 'cel', source: "'Task \"' + record.title + '\" is done.'" } }` | one CEL concatenation; wrap a hole that may be null in `coalesce(…, '')` |
27+
28+
**The one-line fix: write each value of a `subflow` / `map` `input` and a `script`'s `inputs` as a CEL value envelope, and write a child's default into the guard where it should apply.**
29+
30+
**Who is affected, measured.** In this repository: 5 values in 3 nodes of the showcase example (`showcase_task_completed`'s `script`, and the `subflow` nodes of `showcase_task_done_notify_owner` and `showcase_project_closure`), 3 values in the todo example's `task_completion` `script`, 2 docs pages, the service-automation README, 2 dogfood fixtures and the test fixtures; all are migrated in this change. hotcrm's sites at these positions were not measured.
31+
32+
**Still accepted, unchanged.** A CEL value envelope and every literal. 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, a screen's `defaults`. The value-slot retirement's earlier changesets on this line list `subflow.input` among those positions; this one supersedes that line.
33+
34+
### The kit
35+
36+
- **The contract.** `SubflowConfigSchema.input`, `ScriptConfigSchema.inputs` and `MapConfigSchema.input` take `FlowValueSlotSchema` per value (`@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`).
37+
- **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains `subflow.input.*`, `map.input.*` and `script.inputs.*` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries `MapConfigSchema`. `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 maps.
38+
- **The executor.** One per-key resolver, shared with the CRUD `fields` map, evaluates an envelope through `AutomationEngine.evaluateValueEnvelope` in the parent's scope and run context.
39+
- **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: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -226,10 +226,13 @@ node renders the variable as any other: `message: '{{ digest }}'`, and it render
226226
the **evaluated** value.
227227

228228
The same rules hold for the field values of `create_record` / `update_record`
229-
(below): the `assignments` map and the `fields` map are the flow's **value
230-
slots**, and each takes a CEL value envelope or a literal. Only a slot's
231-
top-level value is an expression — an object nested inside a JSON value or an
232-
array is data, whatever keys it carries.
229+
(below), and for the maps a node hands to a **callee**: a `subflow` node's and a
230+
`map` node's `input` (the child flow's input variables — on a `map`, evaluated
231+
once per item with the item variable bound) and a `script` node's `inputs` (the
232+
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.
233236

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

@@ -282,9 +285,19 @@ for some input, so the rewrite is yours to judge.
282285

283286
No spelling is kept: a value slot reads no `{…}` token at all.
284287

288+
**A callee's input: the guarded form supplies `null`.** In a `subflow` or `map`
289+
`input`, a whole token that resolved to nothing handed the child flow nothing,
290+
and the child seeded its input variable from the variable's `defaultValue`. The
291+
guarded form hands `null`, and a `null` the caller supplies is a value: it wins
292+
over the default. So where the child's default should apply to an absent value,
293+
write the default in the guard — `has(vars.x) ? vars.x : 'standard'` — or leave
294+
the key out of `input` where the value is never meant to be supplied. A `script`
295+
function is handed `null` there, where the template handed `undefined`. The
296+
refusal at those positions says so.
297+
285298
**Where an envelope is data, the remedy names none there.** An envelope
286-
evaluates only as the top-level value of the `fields` map or the `assignments`
287-
map. Inside an object or list value, and in the two legacy `assignment` shapes
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
288301
(an `assignments: [{ variable, value }]` array, variables as the config's own
289302
keys), it is stored as the object it spells — so the refusal there names what
290303
does evaluate:
@@ -399,8 +412,9 @@ The built-in `script` executor never evaluates an arbitrary JavaScript string
399412
it **calls a registered function**, and that is the whole of what it does.
400413

401414
**`config.function`** names a function registered through
402-
`defineStack({ functions })`, and it is **required**. `config.inputs` is
403-
`{var}`-interpolated and handed to it; `config.outputVariable` binds the
415+
`defineStack({ functions })`, and it is **required**. `config.inputs` is a value
416+
slot — each value a CEL value envelope evaluated against the flow's variables,
417+
or a literal — handed to it as its `input`; `config.outputVariable` binds the
404418
returned value as a flow variable, so a later declarative node persists it. A
405419
node that names no function refuses before it runs; one naming a function
406420
nothing registered fails the step loudly rather than passing silently.
@@ -439,7 +453,7 @@ compile error carrying the same prescription.
439453
label: 'Score Lead',
440454
config: {
441455
function: 'scoreLead', // registered via defineStack({ functions })
442-
inputs: { rating: '{record.rating}' }, // {var} templates resolve against flow variables
456+
inputs: { rating: { dialect: 'cel', source: 'record.rating' } }, // CEL value envelope, type kept
443457
outputVariable: 'leadScore', // later: fields: { score: { dialect: 'cel', source: 'leadScore' } }
444458
},
445459
}
@@ -1210,6 +1224,7 @@ the map waits for that item's decision, then moves to the next.
12101224
iteratorVariable: 'task',
12111225
flowName: 'one_task_signoff', // the per-item subflow (it may pause)
12121226
itemObject: 'showcase_task', // when an item is a record, it becomes the child's `$record`
1227+
input: { taskId: { dialect: 'cel', source: 'task.id' } }, // evaluated per item, `task` bound
12131228
outputVariable: 'signoffResults',// each item's subflow output, collected in order
12141229
},
12151230
}

‎content/docs/kernel/runtime-services/examples.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export const RollUpOrderTotals = defineFlow({
8282
label: 'Sum the lines',
8383
config: {
8484
function: 'sales.orderTotals',
85-
inputs: { lines: '{lines}' },
85+
inputs: { lines: { dialect: 'cel', source: 'lines' } }, // the list itself, type kept
8686
outputVariable: 'totals',
8787
},
8888
},

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
211211
| **iteratorVariable** | `string` | optional (default: `"item"`) | Variable holding the current item |
212212
| **indexVariable** | `string` | optional | Optional variable holding the current index |
213213
| **itemObject** | `string` | optional | When items are records, the object they belong to (exposes each item as the child's record) |
214-
| **input** | `Record<string, any>` | optional | Params passed to each item's subflow (interpolated per item) |
214+
| **input** | `Record<string, any>` | optional | Params passed to each item's subflow, keyed by its input variables: each value a CEL value envelope `{ dialect: 'cel', source }` evaluated per item (the item variable is in scope), or a literal written as it is — a `{…}` template token is refused |
215215
| **outputVariable** | `string` | optional | Each item's subflow output, collected in order — bound to a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
216216

217217

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ const result = DecisionConditionSchema.parse(data);
163163
| Property | Type | Required | Description |
164164
| :--- | :--- | :--- | :--- |
165165
| **function** | `string` | ✅ | Registered function to call (defineStack(`{ functions }`)). Contractually pure — it returns a value a later declarative node persists |
166-
| **inputs** | `Record<string, any>` | optional | Inputs passed to the function (values interpolate `{token}` templates) |
166+
| **inputs** | `Record<string, any>` | optional | Inputs passed to the function: each value a CEL value envelope `{ dialect: 'cel', source }` evaluated against the live flow variables, or a literal written as it is — a `{…}` template token is refused |
167167
| **outputVariable** | `string` | optional | Flow variable the function's return value is bound to — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
168168
| **actionType** | `never` | optional | [REMOVED] `script.config.actionType` was removed in @objectstack/spec 17 — none of its values did what it said. The two built-ins were logger-backed stubs that recorded the intent and delivered nothing under any configuration, and every other value was a second spelling of `config.function`. Replace it per branch: for `email` use a `notify` node (it delivers through the messaging service — the in-app inbox by default, real email once `@objectstack/plugin-email` is installed); for `slack` use a `connector_action` node with the Slack connector, or an `http` node posting to a webhook; for anything else, move the name into `config.function`. Run `os migrate meta --from 16` to list the mechanical edits for the shorthand case into `config.function`; `--write` applies the ones it can prove, and the stub and marker values are removed. |
169169
| **template** | `never` | optional | [REMOVED] `script.config.template` was removed in @objectstack/spec 17 — it fed only the logger-backed `email`/`slack` stubs, which never rendered or sent a message, so no template id was ever resolved. Delete the key. A `notify` node carries its own `title`/`message`, and stored templates live in the messaging service (`sys_notification_template`), not on the node. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
@@ -181,7 +181,7 @@ const result = DecisionConditionSchema.parse(data);
181181
| Property | Type | Required | Description |
182182
| :--- | :--- | :--- | :--- |
183183
| **flowName** | `string` | ✅ | Flow invoked as this step (it may pause — approval / screen / wait) |
184-
| **input** | `Record<string, any>` | optional | Values passed to the subflow's input variables (interpolate `{token}` templates) |
184+
| **input** | `Record<string, any>` | optional | Values passed to the subflow's input variables: each value a CEL value envelope `{ dialect: 'cel', source }` evaluated in the parent's scope, or a literal written as it is — a `{…}` template token is refused. A value handed over, null included, wins over the child variable's defaultValue |
185185
| **outputVariable** | `string` | optional | Parent flow variable the subflow's output is bound to — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
186186

187187

‎examples/app-showcase/src/automation/flows/index.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,14 @@ export const TaskCompletedFlow = defineFlow({
4848
// A flow function is PURE: it takes `inputs`, RETURNS a value, and a
4949
// later declarative node uses or persists it (#4396).
5050
function: 'summarizeCompletedTask',
51-
inputs: { title: '{record.title}', priority: '{record.priority}' },
51+
// Each input is a CEL value envelope (the `{…}` template dialect is
52+
// retired from value slots). `priority` is optional and a task that
53+
// never set it has no such key, which CEL refuses, so it is guarded;
54+
// the function reads the `null` as "normal".
55+
inputs: {
56+
title: { dialect: 'cel', source: 'record.title' },
57+
priority: { dialect: 'cel', source: 'has(record.priority) ? record.priority : null' },
58+
},
5259
outputVariable: 'summary',
5360
},
5461
},
@@ -718,7 +725,7 @@ export const TaskDoneNotifyOwnerFlow = defineFlow({
718725
triggerType: 'record-after-update',
719726
condition: 'status == "done" && previous.status != "done"',
720727
// The SAME resume-time trap this flow's sibling hit (#7381): the node
721-
// below hops `{record.project.owner}`, and a flow record carries
728+
// below hops `record.project.owner`, and a flow record carries
722729
// `project` as a scalar FK. Un-hydrated it resolved to nothing, the
723730
// subflow's `notify` refused for want of a recipient, and every
724731
// completion of a task ran this flow to a failure. Unlike the invoice
@@ -733,9 +740,13 @@ export const TaskDoneNotifyOwnerFlow = defineFlow({
733740
label: 'Notify Owner',
734741
config: {
735742
flowName: 'showcase_notify_owner',
743+
// CEL value envelopes, evaluated in this flow's scope and handed to the
744+
// subflow's input variables. A project with no owner hands `null`, as
745+
// the template's empty value did, and the subflow's notify then has
746+
// no recipient — guarded, because CEL refuses the absent key.
736747
input: {
737-
ownerId: '{record.project.owner}',
738-
message: 'Task "{record.title}" is done.',
748+
ownerId: { dialect: 'cel', source: 'has(record.project.owner) ? record.project.owner : null' },
749+
message: { dialect: 'cel', source: `'Task "' + record.title + '" is done.'` },
739750
},
740751
outputVariable: 'notifyResult',
741752
},
@@ -843,7 +854,8 @@ export const ProjectClosureFlow = defineFlow({
843854
config: {
844855
flowName: 'showcase_closure_signoff',
845856
input: {
846-
reason: 'Project "{record.name}" was marked completed — please sign off the closure.',
857+
// A CEL concatenation — `name` is required on a project.
858+
reason: { dialect: 'cel', source: `'Project "' + record.name + '" was marked completed — please sign off the closure.'` },
847859
},
848860
outputVariable: 'signoffResult',
849861
},

‎examples/app-todo/src/flows/task.flow.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -366,10 +366,22 @@ export const TaskCompletionFlow: Flow = {
366366
// Registered in `defineStack({ functions })` — see objectstack.config.ts
367367
// and src/functions/task.functions.ts.
368368
function: 'computeNextTaskDueDate',
369+
// CEL value envelopes, evaluated against the flow's variables — the
370+
// `{…}` template dialect is retired from the function's `inputs` too.
371+
// Each field may be absent from the completed task's row, and CEL
372+
// refuses an absent key where the template handed nothing, so each is
373+
// guarded: the function reads `null` exactly as it read the empty
374+
// value (no due date → `null`, no interval → the field's default 1).
369375
inputs: {
370-
dueDate: '{completedTask.due_date}',
371-
recurrenceType: '{completedTask.recurrence_type}',
372-
interval: '{completedTask.recurrence_interval}',
376+
dueDate: { dialect: 'cel', source: 'has(completedTask.due_date) ? completedTask.due_date : null' },
377+
recurrenceType: {
378+
dialect: 'cel',
379+
source: 'has(completedTask.recurrence_type) ? completedTask.recurrence_type : null',
380+
},
381+
interval: {
382+
dialect: 'cel',
383+
source: 'has(completedTask.recurrence_interval) ? completedTask.recurrence_interval : null',
384+
},
373385
},
374386
outputVariable: 'nextDueDate',
375387
},

0 commit comments

Comments
 (0)