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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .changeset/19939-flow-value-slot-date-macros-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
'@objectstack/spec': major
'@objectstack/service-automation': major
'@objectstack/lint': patch
---

A flow VALUE slot now refuses the date macros as well — `{TODAY()}`, `{NOW()}`, with an optional `± N` day offset — so it reads no single-brace `{…}` token at all. Each is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL string form: `isoDate(today())`, `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, `isoDatetime(now())`, `isoDatetime(addDays(now(), N))`.

Clause-②: no (narrowing)

<!-- adr-0087: not-required (already-registered flow-value-slot-template-dialect-refused, flow-text-slot-single-brace-refused) The value-slot retirement's step-18 D3 entry, registered on this line before this change, is amended in this diff to refuse the date macros and name their CEL string forms; the text-slot entry is amended where its remedy computed a date macro through the value-slot spelling this change refuses. No new D3 entry and no D2 conversion. -->

**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`).

**Why.** The template dialect is retired from the value slots: one dialect per slot, with a remedy for every spelling. The date macros were the last spelling kept, because CEL had no string form for a timestamp: `today()` and `now()` reach the data engine as a `Date` object, not the ISO text the macros wrote. The CEL stdlib now has one — `isoDate(t)` (`YYYY-MM-DD`) and `isoDatetime(t)` (`YYYY-MM-DDTHH:mm:ss.sssZ`), on the UTC calendar the macros rendered on — so the macros can be refused with a remedy that writes the same text.

**What is refused.** In every value slot — `create_record` / `update_record` `fields`, the `assignment` node's `assignments` map, and both legacy `assignment` shapes — a string carrying a date macro, alone or beside other text and tokens. A string that mixed a date macro with another token used to be kept whole, the run-user token included (`'Due {TODAY()} by {$User.Id}'`); it is refused now, with a remedy for every token.

**Where the remedy parts from the template.** For no offset or a whole number of days the CEL form writes the template's text byte for byte, at every instant (measured live through both engines over DST transitions, month and year ends and a leap day). The refusal names the three places it does not:

- a fractional offset: the template added it to the day of the month and truncated the sum, `addDays` truncates the offset itself — going back, a day apart on most days of a month — and `daysFromNow(1.5)` / `daysAgo(1.5)` are refused at build;
- a variable offset (`{TODAY() + days}`): the template looked the offset up as one variable name and added 0 days, without a word, when it found none or the value was not a number; CEL reads the path, and fails the run on an absent variable or a value that is not a number;
- an offset that is neither a number nor a variable name (`{TODAY() + 3d}`): the template added 0 days.

**Where an envelope is literal data, the remedy names none there.** The refusal used to prescribe an envelope at whatever position it refused a token. Inside an object or list value, and in the legacy `assignment` shapes, the executor reads an envelope as data: the prescribed metadata registered and stored the envelope object. The refusal there now names what evaluates — the whole value built as one CEL map or list literal (mixed values each wrapped in `dyn(…)`), or the assignment moved into the `assignments` map. This holds for every token, not only the date macros. An envelope-shaped object in those positions is still data, unchanged.

**The `has()` guard a refusal names now holds for an absent variable.** For a path that may be absent, the refusal named a guard on its last key, `has(source.id) ? source.id : null`. `has()` evaluates everything but its last selection, so where the variable itself was never bound — an `isInput` variable the caller left out, with no default — that guard failed the run (`Unknown variable: source`). The guard now tests each step off `vars`, which holds only the variables the run has bound: `has(vars.source) && has(vars.source.id) ? vars.source.id : null` answers `null` there, and where a key on the way is absent or `null`. `flow-bare-dollar-reference`'s value-slot hint carries the same guard. The value-slot retirement's earlier entry on this line prints the last-key guard; this entry supersedes it.

## FROM → TO

| you wrote | write instead | what changes |
|:--|:--|:--|
| `'{TODAY()}'` | `{ dialect: 'cel', source: 'isoDate(today())' }` | nothing |
| `'{TODAY() + 7}'`, `'{TODAY() - 3}'` | `{ dialect: 'cel', source: 'isoDate(daysFromNow(7))' }`, `{ dialect: 'cel', source: 'isoDate(daysAgo(3))' }` | nothing for a whole number of days; a fraction truncates differently, and `daysFromNow` / `daysAgo` refuse one at build |
| `'{NOW()}'` | `{ dialect: 'cel', source: 'isoDatetime(now())' }` | nothing |
| `'{NOW() + 2}'`, `'{NOW() - 1}'` | `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), 2))' }`, `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), -1))' }` | nothing — `addDays` keeps the time of day, where `daysFromNow` would land on midnight |
| `'{TODAY() + days}'` | `{ dialect: 'cel', source: 'isoDate(addDays(today(), days))' }` | an absent `days`, or one that is not a number, fails the run where the template silently added 0 |
| `'Due {TODAY()} by {$User.Id}'` | `{ dialect: 'cel', source: "'Due ' + isoDate(today()) + ' by ' + current_user.id" }` | guard the run user where a flow can run without one |
| `payload: { due: '{TODAY()}' }` (a string inside an object value) | `payload: { dialect: 'cel', source: "{'due': isoDate(today())}" }` | an envelope written at `payload.due` would be stored as an object |
| the guard an earlier refusal named, `has(source.id) ? source.id : null` | `has(vars.source) && has(vars.source.id) ? vars.source.id : null` | `null` where `source` was never bound, where the last-key guard failed the run |
| a text slot's `assignments: { due: '{TODAY() + 7}' }`, then `'Due {{ due }}'` | `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' } }`, then `'Due {{ due }}'` | the text-slot remedy published earlier on this line computed the date through the value-slot spelling this change refuses |

**The one-line fix: write a date macro as `isoDate(…)` / `isoDatetime(…)` in a CEL envelope, with a whole number of days.**

**Who is affected, measured.** This repository's examples carry no date macro in a value slot; its own test fixtures and docs that did are migrated in this change. The census taken for this retirement's first change measured 15 date-macro value-slot sites in hotcrm at `c529de2` (8 `{NOW()}`, 3 `{TODAY()}`, 3 `{TODAY() + N}`, 1 `{TODAY() + var}`); they migrate there. Deployed metadata and other repositories were not measured here.

**Still accepted, unchanged.** A CEL value envelope and every literal. The single-brace dialect, date macros included, keeps resolving where it still lives — a `filter` value, a `notify` `recipients` entry, an `http` body, a `subflow` input. The value-slot retirement's earlier entries on this line list the date macros as still accepted, and the text-slot entry computes a date through them; this entry supersedes those lines.

### The kit

- **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`) keep no token; the text-slot judge's date-macro remedy (`textSlotTemplateRefusal`) names the CEL envelope. `FlowValueSlotSchema` / `AssignmentValueSchema` say so in their descriptions.
- **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the date macros and name their forms) and `flow-text-slot-single-brace-refused` (its date remedy). No key is removed, so there is no tombstone, and there is no D2 conversion.
- **`@objectstack/lint`.** `flow-double-brace-interpolation`'s hint in a value slot names the CEL envelope for the holes the author meant, where it named `{record.title}`, which the slot refuses; `flow-bare-dollar-reference`'s value-slot hint asks the judge at the string's own position, so a nested or legacy-shape string gets the spelling that evaluates there, and carries the judge's step-by-step `has()` guard.
- **`@objectstack/service-automation`'s README.** Its *Expressions* section names the date macros' CEL forms.
52 changes: 35 additions & 17 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -265,19 +265,38 @@ for some input, so the rewrite is yours to judge.

| you wrote | write instead | what changes |
|:---|:---|:---|
| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an **absent** variable or key where the template wrote nothing — guard one that may be absent: `has(record.owner) ? record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) |
| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an **absent** variable or key where the template wrote nothing — guard one that may be absent a step at a time from `vars`, which holds only the variables the run has bound: `has(vars.record) && has(vars.record.owner) ? vars.record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`). A guard on the last key alone does not hold where the variable is absent: `has(source.id)` fails the run when `source` was never bound (an `isInput` variable the caller left out) |
| `'{items.0}'` | `source: 'items[0]'` | an empty list fails the run |
| `'{$error.message}'` | `source: 'vars["$error"].message'` | a `$`-named variable is read through `vars` |
| `'{round(x * 100) / 100}'` | `source: 'round(x * 100) / 100.0'` | CEL divides two integers as integers: keep a decimal operand on every division |
| `'Follow up on {record.name}'` | `source: "'Follow up on ' + record.name"` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` |
| braces meant literally, `'{"a": 1}'` | `source: "'{\"a\": 1}'"` | a CEL string literal |
| `'{$User.Id}'` | `source: 'current_user.id'` | `current_user` is the run's user, and `null` in a run with none (a schedule, a record change made by a system write), where this read fails the run. In a flow that can run without a user, write `current_user != null ? current_user.id : null`: it writes `null` where the template wrote nothing, which on `update_record` clears a stored value the template left alone — or skip the node on `current_user != null` |
| `'{$User.Email}'`, `'{$User.Name}'`, any other `$User` path | read the user record by `current_user.id`: an `assignment` (`uid: { dialect: 'cel', source: 'current_user.id' }`), a `get_record` on `sys_user` with `filter: { id: '{uid}' }` and `outputVariable: 'me'`, then `source: 'me.email'` | these never resolved in any shipped run — they wrote nothing. `current_user` carries only what the run holds: `id`, `positions`, `organizationId`, `isPlatformAdmin` |

One spelling **keeps** its meaning for now, because CEL cannot write it yet:
the date macros (`'{NOW()}'`, `'{TODAY() + 7}'` — CEL's `now()` / `today()` are
timestamps, not the ISO text the macros write, and there is no string form for
one).
| `'{TODAY()}'` | `source: 'isoDate(today())'` | none: `isoDate` writes the `YYYY-MM-DD` text the macro wrote, on the UTC calendar |
| `'{TODAY() + 7}'`, `'{TODAY() - 3}'` | `source: 'isoDate(daysFromNow(7))'`, `source: 'isoDate(daysAgo(3))'` | none for a whole number of days. A fraction (`'{TODAY() - 1.5}'`) is not: the template truncated the day of the month plus the offset, `addDays(today(), -1.5)` truncates the offset, so going back they land a day apart on most days of a month, and `daysAgo(1.5)` is refused at build. Write the whole number of days you mean |
| `'{NOW()}'` | `source: 'isoDatetime(now())'` | none: `isoDatetime` writes the `YYYY-MM-DDTHH:mm:ss.sssZ` text the macro wrote |
| `'{NOW() + 2}'`, `'{NOW() - 1}'` | `source: 'isoDatetime(addDays(now(), 2))'`, `source: 'isoDatetime(addDays(now(), -1))'` | none — `addDays` keeps the time of day, where `daysFromNow` would land on midnight |
| `'{TODAY() + days}'` | `source: 'isoDate(addDays(today(), days))'` | the template added 0 days, without a word, when `days` was absent or not a number; CEL fails the run on either. It also looked the offset up as one name, so `'{TODAY() + record.days}'` added 0 where CEL reads the path |
| `'Due {TODAY()} by {$User.Id}'` | `source: "'Due ' + isoDate(today()) + ' by ' + current_user.id"` | a string mixing a date macro with another token is refused whole, each token spelled as above |

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

**Where an envelope is data, the remedy names none there.** An envelope
evaluates only as the top-level value of the `fields` map or the `assignments`
map. Inside an object or list value, and in the two legacy `assignment` shapes
(an `assignments: [{ variable, value }]` array, variables as the config's own
keys), it is stored as the object it spells — so the refusal there names what
does evaluate:

- a string inside an object or list value: build the **whole value** as one
envelope, a CEL map or list literal — `payload: { note: 'for {name}' }` is
`payload: { dialect: 'cel', source: "{'note': 'for ' + name}" }`. CEL holds
every value of one map, and every element of one list, to one type, so where
they mix — a literal beside a computed value included — wrap each one in
`dyn(…)`: `"{'who': dyn(name), 'n': dyn(3)}"`;
- a legacy `assignment` shape: move the node's assignments into the
`assignments` map, where the envelope evaluates.

[#19939]: https://github.com/objectstack-ai/objectstack/issues/19939

Expand Down Expand Up @@ -327,7 +346,7 @@ check.
| `'Deal won: {record.name}'` | `'Deal won: {{ record.name }}'` |
| `'Failed: {$error.message}'` | `'Failed: {{ $error.message }}'` |
| `'Total {amount * 2}'`, `'{round(x)}'` | compute it into a variable (`assignments: { v: { dialect: 'cel', source: 'amount * 2' } }`), then `'Total {{ v }}'` |
| `'Due {TODAY() + 7}'` | compute it into a variable with an `assignment` node, whose value slot still reads that spelling (`assignments: { due: '{TODAY() + 7}' }`), then `'Due {{ due }}'` |
| `'Due {TODAY() + 7}'` | compute its ISO text into a variable with an `assignment` node's CEL value envelope (`assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' } }`), then `'Due {{ due }}'` |
| `'By {$User.Id}'` | compute the run user's id into a variable with an `assignment` node's CEL value envelope (`assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`), then `'By {{ by }}'` — guarded as `current_user != null ? current_user.id : null` in a flow that can run without a user |

[#22110]: https://github.com/objectstack-ai/objectstack/issues/22110
Expand All @@ -347,8 +366,8 @@ check.
title: { dialect: 'cel', source: "'Follow up on ' + record.name" },
assignee: { dialect: 'cel', source: 'has(record.owner) ? record.owner : null' },
estimate: { dialect: 'cel', source: 'round(record.amount * 0.15 * 100.0) / 100.0' },
// a date macro — one of the two `{…}` spellings a value slot still reads
due_date: '{TODAY() + 7}',
// a date as its ISO text, on the UTC calendar — what `{TODAY() + 7}` wrote
due_date: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' },
status: 'open', // a literal
},
},
Expand Down Expand Up @@ -1994,25 +2013,24 @@ value envelope. **Text** slots — a `notify` title or message, a screen title o
description, a refusing `end` node's message — are `{{ }}` templates (see
[text slots](#text-slots-read-double-brace-holes)). Single braces remain only on the
value-like positions that hand a resolved value over (`recipients`,
`sourceId`, a `subflow` input, an `http` body, …) and in the date macros a value
slot still reads.
`sourceId`, a `subflow` input, an `http` body, …).

| Where | Dialect | Write it like | Bindings |
|:---|:---|:---|:---|
| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*`, `current_user` |
| Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above |
| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, `vars.*` and `current_user` |
| 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.*}` included, except the date macros `{NOW()}` / `{TODAY() ± N}`, which still resolve until CEL can write them) |
| 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) |
| 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`, …) |
| 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 |

A value slot takes either form, chosen by shape: an object naming a `dialect`
is a CEL envelope, everything else is a literal. The `{token}` template dialect
it used to read is retired: `objectstack validate`, `registerFlow` and the
executor refuse a `{…}` token in a value slot with its CEL spelling (see *The
`{…}` template dialect is retired from value slots* above). The date macros are
kept until CEL can write them: CEL's `now()` / `today()` are timestamps, not the
strings the macros write.
`{…}` template dialect is retired from value slots* above). A date macro is its
CEL string form: `isoDate(today())` / `isoDatetime(now())` write the ISO text
the macros wrote, where a bare `today()` / `now()` would write a timestamp.

Every CEL expression in a flow — a condition or a value envelope — sees
**`current_user`**, the run's user: `current_user.id`, `current_user.positions`,
Expand All @@ -2029,8 +2047,8 @@ and is read as `vars["current_user"]` / `vars["vars"]`.

1. **A computed value written as a string** — `due_date: 'TODAY() + 7'` or
`amount: 'price * 2'` writes the literal text into the field. Compute it with
a CEL value envelope (`{ dialect: 'cel', source: 'price * 2' }`); the date
macros keep their braces for now: `'{TODAY() + 7}'`.
a CEL value envelope (`{ dialect: 'cel', source: 'price * 2' }`), a date as
its ISO text (`{ dialect: 'cel', source: 'isoDate(daysFromNow(7))' }`).
2. **Braces put *into* a condition** — `'{record.amount} > 500'`. Conditions
fail loudly rather than silently, with an error that tells you to drop the
braces.
Expand Down
Loading
Loading