Skip to content

Commit 111ffff

Browse files
feat(spec,service-automation,lint)!: a notify node's computed values are value slots and its link a text slot — a CEL envelope or a literal, the {…} token refused (#19939 pass 4, S4) (#22926)
Part of #19939 Clause-②: yes (narrowing: a {…} token is refused at notify recipients (the value or a list entry), templateData values, sourceId, actionUrl, sourceObject, actorId and any string of a payload value, a templateData value that names a dialect must be a valid CEL value envelope, and a CEL value envelope written as a payload value is refused; widening: recipients and sourceId accept a CEL value envelope they refused, a recipients list accepts envelope entries, and their published JSON Schema and inferred types gain the envelope arm) > ~~Held out of the merge queue by #22949.~~ Released 2026-10-12: #22949 closed, and `main` was merged into this branch with the net diff unchanged. The seat queues it once the new head is green and its remaining pre-queue items are met. Pass 4, stage S4 of #19939 (stage plan `6096263424`, claim `6114980625`), under ruling D on #11182 and ADR-0032 Decisions 2 and 3. A `notify` node's computed values become value slots: `recipients` (the value, or each entry of a list), `sourceId` and the values of `templateData` take a CEL value envelope the executor evaluates in the run's scope, or a literal written as it is. Its link, `actionUrl`, becomes a text slot with `{{ }}` holes, like `title`. Its `sourceObject`, `actorId` and `payload` are literals, read as written (round 2, below). A single-brace `{…}` token at any of them is refused. The family finding is fixed and pinned: a list-valued recipient entry adds each of its recipients, where the template handed it on as one joined recipient. #19939 stays open for stages S5 to S7, so this PR uses `Part of`. ## Round 2: the open question answered B (seat order `6117306001`) The round-1 report asked whether `sourceObject`, `actorId` and `payload`, which no flow computed (zero `{…}` tokens in this repository and in hotcrm), should be value slots or literals. The seat answered B in comment `6117306001`, by the dispatch's own Zone 2 rule ("Do not build a slot nobody computes", with S7's connector inputs as the model). This round's diff, `fc5982615c..c200819`: - **The contract.** `sourceObject` and `actorId` are `z.string()` again and `payload` is `z.record(z.string(), z.unknown())`: their 17.x published shapes, with no envelope arm and no marker. The contract's `superRefine` refuses a `{…}` token in `sourceObject`, in `actorId` and in any string of a `payload` value at any depth, and an envelope written as a `payload` value, with the literal's remedy, for example: "`sourceObject` is the name of the object the notification links to, a literal the executor reads as written: a single-brace token is not resolved here; write the value." An envelope at `sourceObject` or `actorId` is refused by the string type, with the same lead. - **The build doors.** Their value arm (`builtinValueJudged`) leaves a token-carrying value alone, because only the interpolation decides what the run sees there. At a literal, the run reads what the build reads. So the contract marks these issues `readAsWritten` (`READ_AS_WRITTEN_PARAM`, in the package-internal `flow-template-token.ts`, which adds no public export; `check:api-surface` is unchanged), and the arm reports them. `FlowSchema.parse`, `objectstack validate`, the save door and `registerFlow` refuse them in the contract's words, as the executor's parse does. - **The ledger and the executor.** The ledger drops the three rows `payload.*`, `sourceObject` and `actorId`. The executor reads the three as written; `resolveStringSlot` keeps `sourceId` only. The descriptor's help strings are restated. - **The lint.** At a notify literal, the `flow-double-brace-interpolation` and `flow-bare-dollar-ref` hints now say that neither brace dialect resolves there, and they name the value itself. Before, they prescribed `{var}`, which the build now refuses there. The three keys are a local set in `lint-flow-patterns.ts`, pinned in step with the contract both ways. - **The text that moves with it.** The D3 entry's surface, replacement, reason and acceptance criteria name the literals. The changeset's FROM / TO and "Newly accepted" lines are rewritten, it gains a "Newly refused at the literals" line, and its `Clause-②` line (read through `clause2-line.mjs`) drops `sourceObject` and `actorId` from the widening arm and adds the payload envelope to the narrowing arm. The generated reference and the dropped-refinements ledger move with it: `automation/NotifyConfig` loses `actorId` and `sourceObject`, 713 → 711. - **`main`** was merged through `os-regen-merge.sh` (`fc5982615c`, from base `85b4ac634e`). It reconciled nothing, and the reference was regenerated after the change. ## What lands **The contract (`@objectstack/spec`).** - `NotifyConfigSchema.recipients` takes a string (one recipient as written), a list whose entries are strings or CEL value envelopes, or one envelope. It is a union, like `screen.recordId`'s, so the published JSON Schema and inferred type keep the literal arms. A value that is none of these is refused with a sentence naming what `recipients` is, and the entry at fault. - `sourceId` takes a string or an envelope (the same union shape). `templateData` takes `FlowValueSlotSchema` per key, like a screen's `defaults`. - A `{…}` token in any of their strings is refused by the one value-slot judge (`valueSlotTemplateRefusals`), naming its CEL spelling. A malformed envelope leads with `VALUE_ENVELOPE_REFUSAL`. - `sourceObject`, `actorId` and `payload` are literals (round 2): their 17.x types, and a token or an envelope there refused with the literal's remedy. - `actionUrl` stays `z.string()`, and the contract's `superRefine` refuses a single-brace token through the one text-slot judge (`textSlotTemplateRefusal`), as for `title` / `message`. - Every `describe()` of these keys is restated. **The ledger.** - `FLOW_NODE_EXPRESSION_PATHS` appends three `notify` value rows: `recipients`, `templateData.*`, `sourceId`. - `recipients` carries a new optional field, `eachElement`: the slot holds one value or a list of them, and both walks (`resolveFlowNodeExpressions`, `resolveFlowNodeValueSlots`) emit each element of a list at its own index (`recipients[1]`). An envelope entry is validated and evaluated where it sits, and a token in a string entry is refused with the envelope at its place, not as a string inside a literal. - `NotifyConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`: the descriptor offers these keys with no marker (the Studio form keeps its recipients picker and text inputs), so the markers ride the spec Zod channel, one slot one channel. - `FLOW_NODE_TEXT_SLOTS` gains `notify.actionUrl`, so `registerFlow` and `objectstack validate` judge it and compile its holes, as for the other text slots. **The judges at the doors.** - `flowNodeValueTemplateRefusals` reaches the three positions through the ledger with no new judge. At `recipients` (the value or an entry) a refused whole token's remedy ends with the recipient sentence: an envelope that evaluates to `null` or a blank string adds no recipient, as a token that resolved to nothing did, and one that evaluates to a list adds each of its recipients. - The build doors' value arm (`flowNodeConfigRefusals`) judges a list slot per element: an issue at an envelope entry is the envelope pass's, and a list whose every entry is a string or envelope-shaped is refused there only for a token or a malformed envelope, which their own judges report. A literal the contract refuses (`[42]`) stays this arm's. **The executor (`@objectstack/service-automation`).** - `notify` resolves `recipients` through `resolveRecipients`: each envelope (the whole value or an entry) through the shared `resolveValueSlot` in the run's scope; a list it evaluates to adds each recipient; `null` and a blank string add none; anything else (a map, a number, a list holding one) fails the node, naming what it evaluated to and its source, never `String(value)`. - A node left with no recipient fails, naming the value expressions that evaluated to nothing (`framework#3582`'s guard, restated for CEL). - `sourceId` resolves through `resolveValueSlot` and must evaluate to a string; `null` leaves the key unset; a half-specified click-through pair is still dropped. `sourceObject`, `actorId` and `payload` are read as written. - `templateData` resolves through `resolveValueSlotMap`, like every value-slot map. - `actionUrl` renders through `renderTextSlot`. A link that renders no text is no link, so the synthesized one still applies downstream, as an empty one did, and an explicit one still wins. - `notify-node.ts` no longer imports `interpolate`. The descriptor's descriptions are restated; its `type`s are unchanged (the form's controls). **The lint (`@objectstack/lint`, patch).** The `flow-double-brace-interpolation` hints name `actionUrl` among the text slots, and the dialect comment no longer lists `recipients` as single-brace. `objectstack validate` refuses the positions through the shared judges and the contract, with no rule change. At a notify literal, both wrong-syntax hints name the value itself (round 2). **ADR-0087.** The step-18 D3 entry `18.flow-value-slot-template-dialect-refused` is amended the S1–S3 way: its surface widens to the notify positions, and its replacement, reason and acceptance criteria say what a recipient evaluates to, that a list adds each recipient and `null` none, why the list token is not rewritten, and that a literal takes the value itself. The two text-slot entries, `18.flow-text-slot-single-brace-refused` and `18.flow-text-slot-unbound-dollar-root-refused`, widen their surface to `actionUrl`, and `STEP18_RATIONALE`'s text-slot fragment (`registry.ts.template`) names the link. `registry.ts` is generated at build and gitignored. The changeset marker is `not-required (already-registered …)` naming the three ids; `check-adr-0087-registration` accepts it. ## Premises measured at the base `9fdb12752c` Measured through the shipped `AutomationEngine` (`installBuiltinNodes`, `registerFlow`, `execute`, a recording messaging service) with a throwaway probe on the base tree, deleted after the run. - **Today's contract.** `NotifyConfigSchema` (`io-node-config.zod.ts:274`–`:353`): `recipients` was `z.union([z.string(), z.array(z.string())])`, required; `sourceObject`, `sourceId`, `actorId`, `actionUrl` were `z.string()`; `templateData` and `payload` were `z.record(z.string(), z.unknown())`. `title` / `message` were the only text slots (`templateExpressionInput`, refused a single brace since #22110). - **The executor's readers** (`notify-node.ts`): `interpolate` ran on `recipients` (`:269`, then `toStringList`), the `templateData` values (`:291`), `actionUrl` (`:298`, then `String(…)`), the `payload` values (`:301`), `sourceObject` / `sourceId` (`:128`–`:129`, then `toStr`) and `actorId` (`:309`). `template` (`:289`), `channels` (`:293`), `topic` (`:294`) and `severity` (`:295`) were read raw. The PM's line numbers held. - **How the doors reached them.** `flowNodeConfigRefusals` parses `NotifyConfigSchema` at `FlowSchema.parse`, `objectstack validate`, `compile`, the save door and `registerFlow` (the contract map entry, `flow-node-config-refusals.ts:125`). Its value arm skips any value carrying a `{token}`, so a token at these keys passed every door; an envelope at the four string-typed keys was refused (measured: `registerFlow` refused `recipients: { dialect: 'cel', … }` and `sourceId: { dialect: 'cel', … }`). No ledger row or judge read these keys. - **`toStringList` (`:70`)**, measured: | `recipients` | delivered audience | |:--|:--| | `'u1'` | `['u1']` | | `'u1,u2'` | `['u1,u2']`: a string is one recipient, never split | | `['u1', 'u2']` | `['u1', 'u2']` | | `['{owners}']`, `owners = ['u1', 'u2']` | **`['u1,u2']`, the run succeeds**: the family finding holds | | `'{owners}'`, the same list | `['u1', 'u2']` | | `['{obj}']`, `obj` a record | `['[object Object]']`, the run succeeds | | `['{missing}', 'u1']` | `['u1']`: the unresolved token is dropped | | `['{missing}']` | `NODE_FAILURE`, "every recipient template resolved to nothing: {missing}", with the `expand` hint | - **The other readers**, measured: `actionUrl: '{obj}'` (a map) sent the link `[object Object]`; `'/x/{when}'` with a `Date` sent `/x/"2026-10-08T09:30:00.000Z"` (JSON-quoted); `'/x/{{ id }}'` became `/x/{42}`; `'{missing}'` sent `url: ''`, and the synthesized link applied. `sourceId: '{obj}'` sent the id `[object Object]`, and `'{missing}'` dropped the pair. `templateData: { a: '{missing}' }` dropped the key, and `templateData: { a: { dialect: 'cel', … } }` handed the template the envelope object. - **CEL, the same inputs** (`evaluateValueEnvelope`): an absent variable throws `Unknown variable: owner`; `record.owner.manager` on a scalar `owner` throws `No such key: manager`; its step-by-step `has(vars.…)` guard answers `null`; `'team:' + owner` with `owner` `null` throws `no such overload`. - **The D2 question: D3 only.** No spelling maps losslessly. `{x}` to `x` at a recipient: an absent `x` was dropped by the template and fails the run under CEL; a list token written as a list entry reached nobody and now reaches each recipient; a record token became `[object Object]` and now fails the node. At `sourceId` an absent value dropped the link and fails the run under CEL. At a `templateData` / `payload` key an absent value dropped the key, and the guarded form writes `null`. At `actionUrl`, `{x}` to `{{ x }}` renders a `Date` and a whole-token object differently (the #22110 measurement, repeated here). So the three step-18 D3 entries are amended and no conversion is registered. - **`#22875`** (the `template` describe) landed during this stage; `main` was merged through `os-regen-merge.sh` and the one generated path it rewrote was regenerated (below). `template` is untouched here. ## The classification One row per key the executor read through `interpolate`, with the token census in shipped metadata and hotcrm (the reach below). | key | S4 | census of `{…}` tokens | why | |:--|:--|:--|:--| | `recipients` | value slot, one value or a list; a list-valued entry is **flattened** | showcase 16, todo 2, docs 6, dogfood 1; hotcrm 19 nodes; the objectui picker writes `{record.FIELD}` | the highest-pull computed value | | `templateData.*` | value slot per key | docs 1; hotcrm 35 values in 19 maps | a template's per-run data | | `sourceId` | value slot (a string) | showcase 7, todo 2, dogfood 2 | the record the link opens | | `sourceObject` | **literal** (round 2), read as written | 0 (every site writes a literal object name) | no flow computes it | | `actorId` | **literal** (round 2), read as written | 0 | no flow computes it | | `payload.*` | **literal** (round 2), read as written | 0 | no flow computes it | | `actionUrl` | text slot, `{{ }}` | showcase 4 links with holes; hotcrm 19 | the plan's carry line: a link is text | **Flattened, not refused.** An entry that evaluates to a list adds each of its recipients. The list is what the author meant (a multi-user field, a team's members, a list variable), the whole-value form already meant a list, and the designer's one-row-per-field picker has no other way to address a multi-user field; refusing it would force every such flow into one hand-written CEL list concatenation. Nested lists, maps and numbers are refused at the node, so a flattened entry can never be a phantom recipient. Pinned (`notify-value-slots.test.ts` 3). **The three zero-census keys are literals** (round 2). Round 1 built them as value slots and reported the choice as an open question. The seat answered B (`6117306001`) by the dispatch's own rule: no slot is built that nobody computes. Each of them resolved a token in 17.x, so a `{…}` there is refused with the literal's remedy, not forwarded verbatim. ## The reach Re-derived at HEAD with a census of every token-bearing string at the seven positions, in `examples/**`, `packages/platform-objects`, `packages/qa/dogfood` and `content/docs/**` (references and releases excluded). Positive control: the base read 41 token strings in 7 files (`recipients` 25, `sourceId` 10, `actionUrl` 4, `templateData` 1, counted by hand for the one map the scan's object regex cuts), and HEAD reads 0. - **Migrated in this PR:** - `examples/app-showcase` (`automation/flows/index.ts`): 16 recipient strings, 7 `sourceId`s and 4 links. A recipient field that may be unset (`assignee`, `owner`, `watcher`, all optional text fields) is guarded, `has(record.assignee) ? record.assignee : null`, so an unset one is dropped as the template dropped it and the node fails as before; `ownerId` (a subflow input) and the ids are plain. The urgent-task alert's two entries are guarded each, and its comment now says what it does: both are notified, and the node fails only when both are absent. The seed's comment and the stale `{record.project.owner}` / `{record.account.owner}` comments follow. - `examples/app-todo` (`flows/task.flow.ts`): 2 recipients (guarded) and 2 `sourceId`s. - Docs: `automation/flows.mdx` (the value-slot list, a new paragraph on recipients, the text-slot list, the dialect table, three samples), `automation/email-templates.mdx` (the sample and the `templateData` sentence), `getting-started/common-patterns.mdx` (two samples). The service-automation README's dialect table. - Dogfood: `seed-ownership-claim-dispatch.dogfood.test.ts` and `fixtures/schedule-organization-fixture.ts`. - `packages/platform-objects` writes no `notify` node. - **Test fixtures:** the notify tests in spec, lint and service-automation, the showcase's `approval-resume-relation-expand.test.ts` (its rolled-back shape is the CEL hop now; see Acceptance notes) and the translation-face and text-slot-list pins. - **hotcrm** (read-only clone at `1d7148bf2d`, deleted after): 19 notify nodes in 15 flow files, each with one `{…}` recipient in a list (2 of them `{$User.Id}`), 35 `{…}` `templateData` values in 19 maps, and 19 links with holes (`/crm_lead/{record.id}`); no `sourceObject`, `sourceId`, `actorId` or `payload`. It is on `@objectstack/spec` 17.7.0, so nothing there moves until it upgrades. - **objectui at the pin `4997995022`** (read-only), the designer's notify pickers, re-read at the pin (the stage-0 lines `:777`, `:856`, `:1103` are the S1/S2 `inputs`, `defaults` and `input` rows there now): - `flow-node-config.ts:1118`: `recipients` renders `FlowRecipientsField`, which writes a record-field row as `{record.FIELD}` (`FlowRecipientsField.tsx:106`) and reads a stored value with `readRecipients` (`:115`–`:118`), which keeps strings only — so a stored envelope entry, or a whole envelope, is dropped from the rows and lost on the next save. - `:1132` `sourceObject`, `:1133` `sourceId`, `:1139` `actionUrl`: `text` fields, whose default `refMode` is `template`, so the `{x}` picker inserts a single-brace token (`VariableTextInput.tsx`, `formatToken`). `:1120` `message`'s placeholder still reads "Supports {var} template references". - The descriptor publishes `recipients` untyped and the other keys with their `type`s, unchanged here, so `mergeServerFlowFields` keeps the hand-written picker and the pinned console still builds. No non-test objectui source imports `NotifyConfig`. - Carried to the objectui companion (objectstack-ai/objectui#12101) by the seat. ## Pins, and the ablations that turn them red - `notify-value-slots.test.ts` (service-automation, 32 engine pins): each position refused at `registerFlow`, located and naming the envelope, and by the executor's contract parse as a guard; the recipient sentence; `actionUrl` refused with its hole spelling at both doors; each envelope evaluated, each literal handed on, a recipient string not split; the family finding (an entry evaluating to a list adds each recipient; never `'u1,u2'`); an absent variable fails with `EXECUTION_ERROR` naming `recipients[1]`; a guarded `null` is dropped; a node left with none fails naming the expressions; a map, and a list holding a number, fail the node naming them; `sourceId` `null` drops the pair and a map fails; `actionUrl` renders a hole, a map as JSON, and a link rendering nothing yields to the synthesized one. - `notify-node.test.ts` re-judged in place: the interpolation fixtures move to envelopes and holes; the `framework#3582` trio now pins the guarded hop's `null` dropped, the node failing with the expression named, and the unguarded hop on a scalar id failing with `No such key: manager`. - Spec: contract pins (`io-node-config.test.ts`: every position takes an envelope and a literal and refuses a token at its own path and a malformed envelope; a list mixes strings and envelopes and names the entry at fault; the build doors' value arm judges a list per element; the JSON projection carries the markers and keeps the literal arms; the describes); ledger pins (`flow-node-expression-paths.test.ts`: the six rows, `eachElement` on `recipients` only, element-by-element emission; the census row list); judge pins (`flow-value-slot-template.test.ts`: located per position, the recipient sentence and where it is absent); the text-slot list (`flow-text-slot-template.test.ts`); the translation face classifies `notify.actionUrl` (`flows-translation-face.test.ts`). - The ratchet (`config-expression-ledger.test.ts`): notify declares its three slots on the spec Zod channel alone; `notify.recipients` joins the channel-required non-predicate slots refused absent. - Lint: `validate-expressions.text-slot.test.ts` (envelopes pass, a token at the link is refused at `error`); `lint-flow-patterns.test.ts` (the double-brace control moves from `actionUrl`, a text slot now, to `sourceId`, a value slot). - **Round 2, the literals.** The engine (`notify-value-slots.test.ts`, section 6, which replaces those keys' rows in sections 1 and 2): a literal is handed on as written, nested `payload` data included; a `{…}` token and an envelope are refused at `registerFlow` (located at the key, in the contract's words, with the literal remedy) and by the executor's contract parse as a guard. The contract (`io-node-config.test.ts`): a literal parses; a token is refused at its own path with the literal remedy, never the value slots' envelope; an envelope is refused; a token at any depth of a `payload` value is located at that string, and a deeper envelope is data; the build doors (`flowNodeConfigRefusals`, `FlowSchema.safeParse`) refuse a token and an envelope in the contract's words; the published JSON Schema keeps the 17.x shapes, with no marker and no envelope arm. `objectstack validate` (`validate-expressions.test.ts`) refuses both at `config.KEY` with the literal remedy, and a literal is clean. The lint hints (`lint-flow-patterns.test.ts`): the contract's literal keys, asked of the contract, are exactly the keys the hints read as literals, and a control key keeps its hint. The ledger, judge and ratchet pins count three notify slots. - **Ablations**, at `384da59997`, each through `scripts/ablation-replace.mjs` in WRAP mode under an outer `trap … EXIT INT TERM`: the anchor hit once, the blob changed, and the restore was proven with the blob equal to HEAD and `git diff HEAD` empty (the outer check found nothing to restore). 1. The executor's `recipients` reading restored to the 17.x `toStringList(interpolate(…))`: 8 of the 32 engine pins went red — every evaluation pin for `recipients`. The refusal pins stayed green, as they must: the contract parse refuses a token before the executor reads anything, so restoring the interpolator cannot reach them. (The dispatch predicted the refusal pins; the measured direction is the evaluation pins.) 2. The ledger's `eachElement` deleted: 5 spec pins went red in 3 files (the element-wise emission, the located recipient refusal and its sentence, and the build doors' per-element value arm). 3. The contract's token refusal for a list entry removed: 1 contract pin went red. - Not ablated at dist level: the service-automation and lint pins read `@objectstack/spec`'s `dist`; ablations 2 and 3 turned the same functions red at source. - **Round-2 ablations**, at `c200819d1e`, the same way (anchor hit once, blob changed, restore proven blob-equal to `HEAD`, `git diff HEAD` empty): 4. The value arm's read-as-written line deleted (`flow-node-config-refusals.ts`): 3 of 73 pins in `io-node-config.test.ts` went red, exactly the three build-door pins (`sourceObject`, `actorId`, `payload`); the contract's own refusal pins stayed green. 5. The contract's literal refusal removed (its `addIssue` replaced by `void refusal`): 8 of 73 went red: the three token refusals, the payload envelope, the payload depth case and the three build-door pins. The `sourceObject` / `actorId` envelope pins stayed green, as they must: the string type refuses those, not the refinement. ## Measurements **Round 2, at `c200819d1e`** (the head; each suite took the verify lock once, on a shared box, so no wall-clock figure is an idle-box reading): | run | result | |:--|:--| | the dependency closure build of service-automation and lint, spec included (`turbo run build --filter='…^...'`) | 29 tasks, exit 0 (after the dropped-refinements line moved: the first build refused, naming `automation/NotifyConfig` without `actorId` and `sourceObject`) | | `@objectstack/service-automation`: the `notify-*` set (all six files) plus `config-expression-ledger` and `config-parse` | 8 files, 122 passed | | `@objectstack/lint`: `lint-flow-patterns`, `validate-expressions`, `validate-expressions.text-slot` | 3 files, 640 passed | | `@objectstack/spec` `vitest run --project local` (full) | 648 files, 19437 passed, 1 todo | | `@objectstack/service-automation` `vitest run` (full) | 188 files, 2459 passed | | `@objectstack/lint` `vitest run` (full) | 137 files, 6537 passed | | `@objectstack/spec` / `service-automation` / `lint` `typecheck` | exit 0 each; the test layers OK against their debt ledgers (spec 52 / 246 / 135 held, service-automation 0, lint 2 / 6 / 2 held) | | `check:generated`, `check:api-surface` ("public API surface + factory signatures unchanged"), `check:authorable-surface`, `check-adr-0087-registration.mjs --base origin/main` (`not-required (already-registered)`) | exit 0 each | - **The gate union** at `c200819d1e`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths derived 116 commands, and 115 were run. 113 exited 0 in the battery. `check:skill-examples` and `check:i18n-coverage` refused with exit 3 (PREREQUISITE NOT MET: `client-react` and the CLI and example closures were unbuilt in the re-created worktree). After the one closure build each names, both exited 0, and so did a re-run of `check:dts-closure`. Reconciled with `--ran`: "116 derived, 115 run, 0 NOT-MEASURED, 1 UNRUN"; the unrun one is `pnpm check:dual-build-cjs-loads`, **NOT MEASURED** by the dispatch's rule. - **⛔ crossed, reported:** the battery ran the derived `pnpm check:type-check-debt` (exit 0, "1 ledger entr(ies) re-measured … none above its recorded number"). That npm script is `--re-measure`, which the dispatch rules out, and it first runs the whole-workspace build `turbo run build --filter=./packages/* --filter=./packages/*/*`, outside the verify lock. That build rewrote `dist/` while my prerequisite build ran, so its first `client-react` declaration build failed (TS7016) and a `check:dts-closure` taken then named `verify`. Both were re-run green once it ended. Round 1's batteries ran the same script. The derivation tree was 8 commits behind `origin/main` (`51b43a8dc4`); 8 gate-input files changed there (workflows and ADR anchors), none of them in this diff. - **ESLint, narrowed and proven**: `eslint --no-inline-config --format json` over the 42 changed `.ts` files at `c200819d1e`: exit 0, 42 files in the JSON report, 0 errors, 0 warnings, none reported ignored. The config enables no type-aware linting (every `parserOptions` is `{ ecmaVersion: 'latest', sourceType: 'module' }`, no `project` / `projectService`; `eslint.config.mjs:327`–`:328`), so this diff cannot move a verdict on an untouched file. The whole-tree `pnpm lint` is CI's. - **The three PR-context guards** are run wired to this PR after this body is written; their readings are in the round-2 `os-dev-report` comment on #19939. - **Not re-run in round 2**: the showcase, todo and dogfood suites and the CLI integration test (round 1, below). This round changes no site: the census found no token at the three keys, so no shipped flow writes one. `origin/main` moved again after the merge (to `51b43a8dc4`, 8 commits, none touching a file in this diff). - **Not measured locally**: `dual-build-cjs-loads` (the dispatch rules out the whole-workspace build), and the CI-owned whole-tree lint. **Round 1** (the heads named; the round-1 diff, before the literals): | run | at | result | |:--|:--|:--| | `@objectstack/spec` build (the dropped-refinements ledger and the generated references included) | `384da59997` | exit 0 | | `@objectstack/spec` `vitest run --project local` (full) | `4c8cf1558f` | 648 files, 19435 passed, 1 todo | | `@objectstack/spec` `vitest run --project repo` (`test:repo`) | `4c8cf1558f` | 55 files, 971 passed | | `@objectstack/spec` `typecheck` (tsc, scripts, test layer) | `4c8cf1558f` | exit 0; the test layer OK against its debt ledger (52 files / 246 errors / 135 signatures held) | | `@objectstack/service-automation` `vitest run` (full, against the rebuilt spec) | `384da59997` | 188 files, 2455 passed | | `@objectstack/service-automation` `typecheck` | `4c8cf1558f` | exit 0; test layer 0 errors | | `@objectstack/lint` `vitest run` (full) | `384da59997` | 137 files, 6508 passed | | `@objectstack/lint` `typecheck` | `4c8cf1558f` | exit 0; the test layer OK against its debt ledger (2 files / 6 errors / 2 signatures held) | | CLI closure build, then `vitest run --project integration test/migrate-meta-engine-guidance.test.ts` | `384da59997` | 3 passed | | `@objectstack/example-showcase` `vitest run` (full) | `d8485a8a50` | 33 files, 409 passed | | `@objectstack/example-todo` `vitest run` (full) | `4c8cf1558f` | 7 files, 238 passed | | `@objectstack/dogfood` (closure build, then the three files this diff feeds: `seed-ownership-claim-dispatch`, `schedule-acting-organization`, `schedule-sweep-organization-scope`) | `4c8cf1558f` | 3 files, 28 passed | - **The first full showcase run went red, and is fixed here.** At `4c8cf1558f` one test failed: `inert-wirings.test.ts`, "derives recipient fields from the flows (guard is not vacuous)". The #7746 guard derived recipient fields with a `{record.FIELD}` regex, so the migrated envelopes left it blind. It now reads the trigger record's direct fields from the envelope sources; at HEAD it derives `showcase_task.assignee`, `showcase_invoice.owner` and `showcase_project.owner` (the base's fields: `{record.assignee}` and `{record.owner}` under the same three start objects), where the old regex derives none. The rolled-back hop's pin in `approval-resume-relation-expand.test.ts` was also re-judged (it asserted the node's id, which a thrown evaluation fault does not carry). - **The gate union**, `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths, at `d8485a8a50`: 116 commands derived, 115 run, every one exit 0. Reconciled with `--ran`: "116 derived, 115 run, 0 NOT-MEASURED, 1 UNRUN"; the one unrun is the next sentence's. `pnpm check:dual-build-cjs-loads` is **NOT MEASURED**: the dispatch rules out the whole-workspace build it needs. **Correction:** round 1 said here that `check:type-check-debt` ran without `--re-measure`. That was wrong. The derived command `pnpm check:type-check-debt` IS `check-type-check-coverage.mjs --re-measure` (root `package.json:195`), which also runs the whole-workspace closure build first. So both ⛔s were crossed, in every battery of both rounds, unlocked. See the round-2 measurements. - **The artifact rosters** the no-path run prints (50): 47 run at `d8485a8a50`, every one exit 0 (14 of them are checker-health `--self-test` runs, not PR verdicts), including the five whose roster sits in a directory this diff touches (`check-changeset-fixed`, `check:meta-url-spelling`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`). The three PR-context guards (`check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`) are run wired to this PR once it is open; their readings are in the `os-dev-report` comment on #19939. - **The consumer gates the dispatch names**, inside the union: `check-adr-0087-registration.mjs --base origin/main` (exit 0, "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", `not-required (already-registered)`), `check:generated` (exit 0), `check:nul-bytes` (exit 0), `check:doc-authoring` (exit 0). `check:migration-registry` no longer exists (removed by #22554; see the notes). - **ESLint, narrowed and proven**: `eslint --no-inline-config --format json` over the 40 changed `.ts` files at `d8485a8a50`: exit 0, 40 files in the JSON report, 0 errors, 0 warnings, none reported ignored. The population is the changed source files the config's `files` globs match. The narrowing excludes nothing that could move: `eslint.config.mjs` enables no type-aware linting (every `parserOptions` is `{ ecmaVersion: 'latest', sourceType: 'module' }`, no `project` / `projectService`, no typed rules; the config says so at `:327`–`:328`), so this diff cannot move a verdict on an untouched file. The whole-tree `pnpm lint` is CI's. - **The tree the union was derived from** is 5 commits behind `origin/main` (`85b4ac634e`) at the time of the run; none of them touches a file in this diff, and the one gate file among them, `scripts/check-verify-stand-in-erasure.mjs`, adds two allowlist entries for exports #22907 added to `packages/verify`, which this diff does not touch. - **Not measured locally**: `dual-build-cjs-loads` (above), and the CI-owned whole-tree lint. Turbo's `--affected` set was not used to narrow anything here. ## Out of the claim's file surface, and why The claim's file surface (`6114980625`) was held, with these files beside it; each one is named here with why. - `spec/automation/flow-node-config-refusals.ts` (18 lines): the build doors' value arm judges a list slot per element when the ledger row carries `eachElement`. Without it, the arm reads a list as one value that is not an envelope, so a list holding a malformed envelope entry or a token is refused a second time there, as the contract's union refusing the whole list, beside the envelope pass's or the judge's located refusal (pinned in `io-node-config.test.ts`, "the build doors"). - `spec/automation/schemaless-node-config.zod.ts` (11 lines): `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` gains `notify`, the channel that carries its `xExpression` markers to the reconciliation ratchet, since the descriptor carries none (its form keeps the recipients picker). - `spec/automation/flow-text-slot-template.ts`: the claim scoped it to a notify remedy sentence; the change is one row in `FLOW_NODE_TEXT_SLOTS` (`notify.actionUrl`), the list that makes a key a text slot at every door, and its docblocks. - The two text-slot D3 entries, `18.flow-text-slot-single-brace-refused.ts` and `18.flow-text-slot-unbound-dollar-root-refused.ts`: the claim names only the value-slot entry; their surface lists the text slots, and `actionUrl` joins it (the plan's "each stage amends the D3 entries"). - `spec/dropped-refinements.baseline.json`: the spec build refused until the `automation/NotifyConfig` row named its five new projection-dropped refinements (`actorId`, `recipients`, `sourceId`, `sourceObject`, `templateData.valueType`; header 708 to 713). - Comments and docs only: `spec/automation/builtin-node-config.zod.ts` (two docblocks listing the value-slot positions), service-automation `engine.ts` (the value-slot docblock), `builtin/template.ts` (a comment that listed `recipients` as a single-brace position) and `README.md` (the dialect table). - Spec test fixtures outside the notify tests that wrote a notify token: `api/zod-issues-to-fields.test.ts`, `flow-builtin-node-config-keys.test.ts`, `flow-node-config-required.test.ts`, `flow-region-pause-and-end.test.ts`; and the two list pins, `builtin-node-config.test.ts` (the ledger-declared keys) and `system/flows-translation-face.test.ts` (`notify.actionUrl` classified as a text slot with no reader). - Docs beyond `flows.mdx`, as the census found them: `automation/email-templates.mdx` and `getting-started/common-patterns.mdx`; the generated `references/automation/io-node-config.mdx`. - Round 2: `spec/automation/flow-template-token.ts` (the package-internal home of `READ_AS_WRITTEN_PARAM`, which is not a public export, beside the token grammar the notify contract now reads), `flow-node-config-refusals.ts` again (the value arm reads the marker), `lint/src/lint-flow-patterns.ts` (the literal hints) and `lint/src/validate-expressions.test.ts` (the `objectstack validate` pins). - Not touched: `template`, `channels`, `topic`, `severity`; S5 to S7; `skills/**`; objectui; hotcrm. ## Acceptance notes - **The open question is answered B** (seat order `6117306001`, in the non-escalated class, with the maintainer's veto window open): `sourceObject`, `actorId` and `payload` are literals, and the round above implements it. - **The relation-hop hint.** Under the template a hop on a scalar id (`record.account.owner` without the start node's `expand`) resolved to nothing, and the empty-recipients guard named it with the `expand` remedy. As a CEL value the hop fails the evaluation itself: `recipients[0]: value expression failed to evaluate as CEL: No such key: owner`, with its source. Neither that message nor the restated empty-recipients guard (which now names the expressions that evaluated to nothing, and says `null`, a blank string and an empty list add none) carries the base's `expand` remedy: the fault names the key and the source, and the hint to hydrate the relation is gone, as it is at every value slot since S1, where the fault is the engine's one message. Restoring it is a polish of that shared message, noted here, not filed. The showcase's `approval-resume-relation-expand.test.ts` pins the rolled-back shape on the new message. An evaluation fault is thrown, as at every value slot since S1, so the run's message does not carry the node's id (the step and `$error.nodeId` do); the returned empty-recipients failure does, as before. - **The designer, until the objectui companion lands** (objectstack-ai/objectui#12101, carried by the seat): the recipients picker writes `{record.FIELD}`, which `registerFlow`, the save door and `objectstack validate` now refuse, naming its envelope; and `readRecipients` keeps strings only, so a stored envelope entry is dropped from the rows and lost on the next save. The `sourceObject`, `sourceId` and `actionUrl` text inputs insert single-brace tokens from the `{x}` picker. - **The skill text, until #22585 lands** (carried by the seat), measured on `main` at `eab7236ff7`: `skills/objectstack-automation/SKILL.md:107` writes `recipients: '{record.assignee}'` and `:114` `sourceId: '{record.id}'`, both refused after this lands; `:115`'s link is a text slot now; `:240` lists `recipients` among the single-brace slots. `:107`'s comment also says a recipient may be a CSV, and at the base a CSV string is one recipient: `'u1,u2'` was delivered as the one audience entry `'u1,u2'`, which the messaging service's recipient resolver reads as a bare user id (`recipient-resolver.ts:143`). - **The ablation's direction** differs from the dispatch's prediction, as measured above: restoring the interpolator reddens the evaluation pins; the refusal pins are the contract's and cannot be reached from the executor. - **The lint's three literal keys** are a local set in `lint-flow-patterns.ts`, because the spec publishes no list of them and a new export would widen the API surface for a hint. A pin asks the contract which keys refuse a token with the literal lead, and holds the set to exactly those. - **The designer's `sourceObject` text input** (objectui pin `:1132`) inserts a single-brace token from its `{x}` picker. That token is refused at save now, with the literal remedy. This is carried to objectstack-ai/objectui#12101 with the other pickers. - **The shared resolver's literal arm.** `resolveValueSlot` hands a non-envelope value to `interpolate` (S1's shape); a literal reaching it carries no token, since the contract parse refused one, so it comes back as written. `notify-node.ts` itself no longer imports `interpolate`. - **`check:migration-registry`**, named in the dispatch's consumer list, was removed by #22554 (the registry is generated at build): `gen:migration-registry` ran inside each spec build, test and typecheck above, and `src/migrations` is green. - **Base**: round 1 merged `main` at `19d5a3e6d9`, and round 2 at `fc5982615c` (base `85b4ac634e`); `main` has moved since (to `51b43a8dc4`), touching no file in this diff. --- _Generated by [Claude Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 4e6b270 commit 111ffff

50 files changed

Lines changed: 1985 additions & 350 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/service-automation': major
4+
'@objectstack/lint': patch
5+
---
6+
7+
A `notify` node's computed values are value slots now: `recipients`, `sourceId` and the values of `templateData` take a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, that the executor evaluates in the run's scope, or a literal written as it is. `recipients` holds one recipient or a list of them, each entry a string or an envelope, and an entry that evaluates to a list adds each of its recipients. The node's link, `actionUrl`, is a text slot like `title`: it renders `{{ }}` holes, `'/crm_case/{{ record.id }}'`. Its `sourceObject`, `actorId` and `payload` are literals, read as written: no flow computed one, so nothing is evaluated there. A single-brace `{…}` token at any of these positions is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling at a value slot, its `{{ }}` spelling at `actionUrl`, and, at a literal, the remedy to write the value.
8+
9+
Clause-②: yes (narrowing: a {…} token is refused at notify recipients (the value or a list entry), templateData values, sourceId, actionUrl, sourceObject, actorId and any string of a payload value, a templateData value that names a dialect must be a valid CEL value envelope, and a CEL value envelope written as a payload value is refused; widening: recipients and sourceId accept a CEL value envelope they refused, a recipients list accepts envelope entries, and their published JSON Schema and inferred types gain the envelope arm)
10+
11+
<!-- adr-0087: not-required (already-registered flow-value-slot-template-dialect-refused, flow-text-slot-single-brace-refused, flow-text-slot-unbound-dollar-root-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 notify node's computed values and to its three literals, and its replacement, reason and acceptance criteria say what a recipient evaluates to, that a list adds each recipient and null none, why the list token is not rewritten, and that a literal takes the value itself. The two text-slot D3 entries widen their surface to the notify node's actionUrl. No new D3 entry and no D2 conversion: an absent variable dropped a recipient under the template and fails the run under CEL, a list token written as a list entry reached nobody under the template and reaches each recipient as an envelope, and a Date in a link rendered JSON-quoted under the interpolator and as its ISO text under the template engine. -->
12+
13+
**BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (four positions accept 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.*`).
14+
15+
**Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a link with holes is text. Until now the executor ran each of these positions through the single-brace interpolator, and one of its answers reached nobody: a whole token holding a list, written as an entry of a `recipients` list — `recipients: ['{owners}']` with `owners = ['u1', 'u2']` — was handed to the messaging service as ONE recipient, `'u1,u2'`, and the run reported success. A token holding a record there became the recipient `'[object Object]'`, and so did one in a link or a `sourceId`.
16+
17+
**What changes where a value may be absent.** A recipient envelope that evaluates to `null` or a blank string adds no recipient, as a token that resolved to nothing did, and a node left with none fails, naming the expressions that evaluated to nothing. An absent variable or key fails the run under CEL, naming it, where the template dropped the recipient; guard a field that may be unset, `has(record.assignee) ? record.assignee : null`. A relation hop on a scalar id (`record.account.owner` without the start node's `expand`) fails the evaluation itself, naming the key it could not read, where the template resolved it to nothing. `sourceId` must evaluate to a string; `null` leaves the key unset, and a half-specified click-through pair is still dropped. A recipient, or a `sourceId`, that evaluates to anything else — a map, a number — fails the node, naming what it evaluated to.
18+
19+
**The literals.** `sourceObject`, `actorId` and the values of `payload` passed through the single-brace interpolator too, but no flow computed one: the census found no `{…}` token at any of them, in this repository or in hotcrm. So no value slot is built there. They are read as written, and a `{…}` token in one — in any string of a `payload` value, at any depth — is refused with the literal's remedy: a single-brace token is not resolved here; write the value. An envelope written there is refused too, since nothing evaluates it (a deeper one inside a `payload` value is data, as it always was).
20+
21+
**What does not change.** A literal at every value slot is written as it is: a recipient string (`'team:ops'`, a user id, an email address) is one recipient, as before, and is not split. `template`, `topic`, `channels` and `severity` are read raw, as before. An explicit `actionUrl` still wins over the link synthesized from `sourceObject` / `sourceId`, and a link that renders no text yields to it, as an empty one did.
22+
23+
## FROM → TO
24+
25+
| you wrote | write instead | what changes |
26+
|:--|:--|:--|
27+
| `recipients: '{record.owner}'` | `recipients: { dialect: 'cel', source: 'record.owner' }` | an absent `owner` fails the run instead of leaving the node without a recipient |
28+
| `recipients: ['{record.assignee}']` where it may be unset | `recipients: [{ dialect: 'cel', source: 'has(record.assignee) ? record.assignee : null' }]` | none: the `null` is dropped, as the unresolved token was |
29+
| `recipients: ['{owners}']` (a list variable) | `recipients: [{ dialect: 'cel', source: 'owners' }]` | each of its recipients is notified, where the template sent one joined string |
30+
| `recipients: ['{$User.Id}']` | `recipients: [{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }]` | none: a run with no user adds no recipient |
31+
| `sourceId: '{record.id}'` | `sourceId: { dialect: 'cel', source: 'record.id' }` | an absent id fails the run instead of dropping the link |
32+
| `sourceObject: '{record.object_name}'`, `actorId: '{record.owner}'`, `payload: { who: '{record.owner}' }` | the value itself: `sourceObject: 'crm_case'` | the key is a literal now; nothing computes it |
33+
| `templateData: { name: '{record.name}' }` | `templateData: { name: { dialect: 'cel', source: 'record.name' } }` | the template gets `null` (a guard's answer) where the key was absent |
34+
| `actionUrl: '/crm_case/{record.id}'` | `actionUrl: '/crm_case/{{ record.id }}'` | a `Date` in the link renders as its ISO text, not JSON-quoted |
35+
36+
**The one-line fix: write each `notify` value position as a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, double the braces in `actionUrl`, and write `sourceObject`, `actorId` and `payload` as the values themselves.**
37+
38+
**Who is affected, measured.** In this repository, at the base: the showcase example (16 recipient strings, 7 `sourceId`s and 4 links), the todo example (2 recipients and 2 `sourceId`s), the flows guide, the email-templates and common-patterns guides, two dogfood fixtures and test fixtures; all are migrated in this change, and a recipient field that may be unset is guarded. hotcrm at `1d7148bf2d` writes 19 notify nodes in 15 flow files, each with one `{…}` recipient (2 of them `{$User.Id}`), 35 `{…}` `templateData` values and a link with holes; it is on spec 17.7.0, so nothing there moves until it upgrades.
39+
40+
**Newly accepted.** A CEL value envelope at `recipients` (the value, or each entry of a list) and `sourceId`: their published JSON Schema gains the envelope arm, and so do the inferred `NotifyConfig` types. At a `templateData` key an object naming a `dialect` was data before; it is an expression now, evaluated per run, and a malformed one is refused. `sourceObject`, `actorId` and `payload` keep their published shapes (a string, a string, a map of any values), with no envelope arm.
41+
42+
**Newly refused at the literals.** A `{…}` token in `sourceObject`, in `actorId` or in any string of a `payload` value, and a CEL value envelope written as a `payload` value, which was handed on as the object it spells.
43+
44+
**Still accepted, unchanged.** Every literal at these positions. The single-brace dialect keeps resolving where it still lives: a `filter` value, an `http` body. The value-slot retirement's earlier changesets on this line, and the text-slot changeset, list `recipients`, `actionUrl`, `sourceId`, `payload` and `templateData` among those positions; this one supersedes that line.
45+
46+
### The kit
47+
48+
- **The contract.** `NotifyConfigSchema`: `recipients` takes a string, a list of strings and envelopes, or one envelope; `templateData` takes `FlowValueSlotSchema` per key; `sourceId` takes a string or an envelope; `actionUrl` refuses a single-brace token through the text-slot judge; `sourceObject`, `actorId` and `payload` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. That refusal is marked read-as-written, so the build doors' value arm reports it at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated.
49+
- **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains three `notify` value rows. `recipients` carries `eachElement`: a list is read entry by entry, at its index (`recipients[1]`). `FLOW_NODE_TEXT_SLOTS` gains `notify.actionUrl`. `NotifyConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the markers to the reconciliation ratchet.
50+
- **The executor.** The `notify` node evaluates each envelope through the shared value-slot resolver, renders `actionUrl` through the text renderer and reads the literals as written; it no longer imports the single-brace interpolator.
51+
- **The lint.** The `flow-double-brace-interpolation` hint names `actionUrl` among the text slots, and at a notify literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract.
52+
- **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion.

‎content/docs/automation/email-templates.mdx‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,9 @@ the localizable path:
160160
type: 'notify',
161161
label: 'Notify Owner',
162162
config: {
163-
recipients: '{record.owner_id}',
163+
recipients: { dialect: 'cel', source: 'record.owner_id' },
164164
template: 'crm.task_done',
165-
templateData: { 'task.title': '{record.title}' },
165+
templateData: { 'task.title': { dialect: 'cel', source: 'record.title' } },
166166
},
167167
}
168168
```
@@ -184,9 +184,12 @@ the localizable path:
184184
to every recipient verbatim. The two paths cannot be combined on one node —
185185
the schema refuses the ambiguous shape rather than letting a runtime
186186
precedence rule silently drop one.
187-
- `templateData` **values** are interpolated per run, so `{record.x}` works in
188-
them. `template` itself is read **raw** — it is a static metadata
189-
cross-reference, and a `{token}` there is forwarded verbatim, never resolved.
187+
- `templateData` **values** are value slots: a CEL value envelope —
188+
`{ dialect: 'cel', source: 'record.title' }` — is evaluated per run and its
189+
value handed to the template, and any other value is handed on as written. A
190+
single-brace `{record.x}` token is refused there. `template` itself is read
191+
**raw** — it is a static metadata cross-reference, and a `{token}` there is
192+
forwarded verbatim, never resolved.
190193

191194
### From code
192195

‎content/docs/automation/flows.mdx‎

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,12 @@ registered function's `input`). Those maps — `assignments`, `fields`, `input`,
233233
`inputs` — are the flow's **value slots**, and so are a `screen` node's three
234234
positions that hand a value to the client: an object form's `defaults` (each
235235
field's prefill), a field's `defaultValue`, and an object form's `recordId`,
236-
and the list a `loop` (with a `body`) and a `map` iterate, their `collection`.
237-
Each value takes a CEL value envelope or a literal — a `collection`'s literal is
238-
an inline array, never a string. Only a slot's top-level
236+
the list a `loop` (with a `body`) and a `map` iterate, their `collection`, and a
237+
`notify` node's computed values: its `recipients`, its `templateData` map and
238+
its `sourceId`. Each value
239+
takes a CEL value envelope or a literal — a `collection`'s literal is an inline
240+
array, never a string, and a `recipients` list holds one entry per recipient,
241+
each a string or an envelope. Only a slot's top-level
239242
value is an expression — an object nested inside a JSON value or an array is
240243
data, whatever keys it carries.
241244

@@ -332,10 +335,31 @@ string is refused there whatever it holds, a variable's **bare name** included
332335
every run failed at the node. A `loop` with no `body` is the legacy flat-graph
333336
form, which still reads `collection` as a bare variable name, unjudged.
334337

338+
**A `notify` node's recipients: a list adds each recipient.** `recipients`
339+
holds one recipient — a user id, or an audience selector such as `team:ID` — or
340+
a list of them, each a string or an envelope:
341+
`recipients: ['team:ops', { dialect: 'cel', source: 'record.assignee' }]`. An
342+
envelope may evaluate to a recipient; to a list of them — a multi-user field, a
343+
list variable — whose every recipient is added; or to `null` or a blank string,
344+
which adds none, as the template dropped a token that resolved to nothing. A
345+
node left with no recipient fails, naming the expressions that evaluated to
346+
nothing. The template handed a whole token holding a list, written as a list
347+
entry (`['{owners}']`), on as **one** recipient joined with commas (`'u1,u2'`),
348+
which reached nobody. Guard a recipient field that may be unset —
349+
`has(record.assignee) ? record.assignee : null` — so it is dropped rather than
350+
failing the run. `sourceId` takes a string or an envelope evaluating to one:
351+
`null` leaves the key unset, and a half-specified click-through pair is
352+
dropped, as before. The node's `sourceObject`, `actorId` and `payload` are
353+
**literals**, read as written: no flow computed one, so nothing is evaluated
354+
there — write the value itself (`sourceObject: 'crm_case'`). A `{…}` token
355+
there is refused, since it would reach the messaging service with its braces,
356+
and so is an envelope. The node's `actionUrl` is a text slot (below).
357+
335358
**Where an envelope is data, the remedy names none there.** An envelope
336359
evaluates only as the top-level value of a value slot — `fields`,
337360
`assignments`, a callee's `input` / `inputs`, a screen's `defaults`,
338-
`defaultValue` or `recordId`, or a `collection`. Inside an object or list value, and in the two legacy `assignment` shapes
361+
`defaultValue` or `recordId`, a `collection`, or a `notify` value (each entry of
362+
a `recipients` list among them). Inside an object or list value, and in the two legacy `assignment` shapes
339363
(an `assignments: [{ variable, value }]` array, variables as the config's own
340364
keys), it is stored as the object it spells — so the refusal there names what
341365
does evaluate:
@@ -355,19 +379,21 @@ does evaluate:
355379

356380
### Text slots read double-brace holes
357381

358-
A flow's **text** slots — a `notify` node's `title` and `message`, a `screen`
359-
node's `title` and `description`, and a refusing `end` node's `message` —
360-
render through the formula template engine ([#22110], ADR-0032 §3): each
382+
A flow's **text** slots — a `notify` node's `title`, `message` and `actionUrl`
383+
(the link, since [#19939](https://github.com/objectstack-ai/objectstack/issues/19939)), a `screen` node's `title` and `description`, and a
384+
refusing `end` node's `message` — render through the formula template engine
385+
([#22110], ADR-0032 §3): each
361386
`{{ }}` hole is a **variable path** with an optional **formatter**, and every
362387
other character is literal.
363388

364389
```typescript
365390
{
366391
id: 'notify_won', type: 'notify', label: 'Deal won',
367392
config: {
368-
recipients: '{record.owner}', // not a text slot: single brace
393+
recipients: { dialect: 'cel', source: 'record.owner' }, // a value slot: a CEL envelope
369394
title: 'Deal won: {{ record.name }}',
370395
message: '{{ record.amount | currency }} closed on {{ record.close_date | date:long }}.',
396+
actionUrl: '/crm_opportunity/{{ record.id }}', // a text slot: a link with holes
371397
},
372398
}
373399
```
@@ -843,7 +869,7 @@ processed, and the run completes.
843869
nodes: [
844870
{
845871
id: 'notify_owner', type: 'notify', label: 'Notify owner',
846-
config: { title: 'SLA breach', recipients: ['{currentCase.owner}'] },
872+
config: { title: 'SLA breach', recipients: [{ dialect: 'cel', source: 'currentCase.owner' }] },
847873
},
848874
],
849875
edges: [],
@@ -2064,11 +2090,11 @@ failures so one broken flow does not abort startup.
20642090

20652091
A flow computes with **one expression dialect, CEL**: every condition is bare
20662092
CEL, and every computed value (a field value, an assignment value) is a CEL
2067-
value envelope. **Text** slots — a `notify` title or message, a screen title or
2068-
description, a refusing `end` node's message — are `{{ }}` templates (see
2069-
[text slots](#text-slots-read-double-brace-holes)). Single braces remain only on the
2070-
value-like positions that hand a resolved value over and that the retirement
2071-
has not reached yet (`recipients`, `sourceId`, an `http` body, …).
2093+
value envelope. **Text** slots — a `notify` title, message or link, a screen
2094+
title or description, a refusing `end` node's message — are `{{ }}` templates
2095+
(see [text slots](#text-slots-read-double-brace-holes)). Single braces remain
2096+
only on the value-like positions that hand a resolved value over and that the
2097+
retirement has not reached yet (an `http` body, a CRUD `filter` value, …).
20722098

20732099
| Where | Dialect | Write it like | Bindings |
20742100
|:---|:---|:---|:---|
@@ -2077,7 +2103,7 @@ has not reached yet (`recipients`, `sourceId`, an `http` body, …).
20772103
| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, `vars.*` and `current_user` |
20782104
| Field values and assignment values, as a **literal** | none — written as it is | `'open'`, `42`, `true`, `['a', 'b']` | — (a `{…}` template token is refused here since [#19939](https://github.com/objectstack-ai/objectstack/issues/19939), `{$User.*}` and the date macros `{NOW()}` / `{TODAY() ± N}` included: a date is `isoDate(today())` / `isoDatetime(now())` in an envelope) |
20792105
| Field values and assignment values, as a **CEL value envelope** | **CEL** (in an envelope) | `{ dialect: 'cel', source: 'round(price * 100.0) / 100.0' }` | flow variables by name, `vars.*` and `current_user` — the whole CEL stdlib (`joinNonEmpty`, …) |
2080-
| Text slots — `notify` `title` / `message`, `screen` `title` / `description`, `end` `message` | **template** (`{{ }}` holes) | `'Deal won: {{ record.name }} ({{ record.amount \| currency }})'` | flow variables by name (`$`-named ones too: `{{ $error.message }}`) — a path and an optional formatter, never logic |
2106+
| Text slots — `notify` `title` / `message` / `actionUrl`, `screen` `title` / `description`, `end` `message` | **template** (`{{ }}` holes) | `'Deal won: {{ record.name }} ({{ record.amount \| currency }})'` | flow variables by name (`$`-named ones too: `{{ $error.message }}`) — a path and an optional formatter, never logic |
20812107

20822108
A value slot takes either form, chosen by shape: an object naming a `dialect`
20832109
is a CEL envelope, everything else is a literal. The `{token}` template dialect
@@ -2359,7 +2385,7 @@ export const hotLeadFollowUp: Flow = {
23592385
type: 'notify',
23602386
label: 'Notify Owner',
23612387
config: {
2362-
recipients: '{record.owner}',
2388+
recipients: { dialect: 'cel', source: 'record.owner' },
23632389
title: 'New hot lead',
23642390
message: 'Hot lead created: {{ record.name }}',
23652391
channels: ['inbox'],

0 commit comments

Comments
 (0)