Skip to content

Commit d85615d

Browse files
feat(spec,service-automation)!: refuse the date macros in flow value slots with their CEL string form (#19939 pass 3) (#22635)
Part of #19939 Clause-②: no (narrowing) Pass 3 of #19939, under ruling D on #11182 (maintainer 「11182 D 其他同意」): the date macros `{TODAY()}` / `{TODAY() ± N}` / `{NOW()}` / `{NOW() ± N}` leave the flow value slots, refused with their CEL string form. With them, a value slot reads no single-brace token at all. The pass also carries the three carries named in the claim (`6067854904`, `6092547738`, and the kept-spelling leak from `6092730938`), plus seat 3's pointer `6094071127` on the `has()` guard. #19939 stays open for the six rider positions, so this PR uses `Part of`. This branch was built by two dev runs. The first stopped before opening a PR. This run merged `main` (`d748ae80af`) through `os-regen-merge.sh`, added the guard fix, and re-ran every reading below on the final head. ## What lands **The refusal (`@objectstack/spec`).** `valueSlotTemplateRefusals` keeps no token kind. A date macro, alone or beside other text and tokens, is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL string form (`celDateMacro` in the package-internal token module): | you wrote | the refusal names | |:--|:--| | `'{TODAY()}'` | `isoDate(today())` | | `'{TODAY() + N}'`, `'{TODAY() - N}'` | `isoDate(daysFromNow(N))`, `isoDate(daysAgo(N))` | | `'{NOW()}'` | `isoDatetime(now())` | | `'{NOW() + N}'`, `'{NOW() - N}'` | `isoDatetime(addDays(now(), N))` with `N` signed; `daysFromNow` would land on midnight | | `'{TODAY() + days}'` | `isoDate(addDays(today(), days))` | **The edges the remedy names** (carry `6067854904` item 2), each said in the refusal itself: - A fractional offset. The template added the offset to the day of the month and truncated the sum, while `addDays` truncates the offset. Going back, they land a day apart once the day of the month passes the offset. `daysAgo(1.5)` / `daysFromNow(1.5)` are refused at build. - A variable offset. 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, an absent variable fails the run, and a value that is not a number is an invalid date that `isoDate` refuses. - An offset that is neither a number nor a variable name (`{TODAY() + 3d}`). The template added 0 days, so the remedy writes `isoDate(today())` and says why. **The kept-spelling leak** (`6092730938`). `'Due {TODAY()} by {$User.Id}'` was kept whole while the macros were kept. It is refused now with one concatenation, `'Due ' + isoDate(today()) + ' by ' + current_user.id`, and a sentence per token (the run user's guard included). **Where an envelope is literal data, the remedy names none there** (carry `6092547738` item 1). At an element of the legacy `assignments` array, a key of the bare legacy config, and a string inside an object or list literal, the executor reads an envelope as data. The judge used to prescribe one at that position, and the run then stored the envelope object. **Chosen: judge those positions as non-evaluating.** The refusal there names what does evaluate: - the whole value built as one envelope, a CEL map or list literal holding the string's CEL spelling at its place (`{'note': 'for ' + name}`), with mixed values each wrapped in `dyn(…)`; - or, for a legacy shape, the node's assignments moved into the canonical `assignments` map. Why not make the executor evaluate envelopes there: an envelope-shaped object at those positions is data today, pinned by `the legacy shapes are untouched` and the CRUD nested-envelope pin. Evaluating it would change what an existing flow writes. It would also need the spec ledger to declare those positions as value slots, a contract change out of proportion to a remedy text. The judge-side fix is text only, consistent with how the executor reads the positions, and pinned through the engine: each named spelling, read off the refusal and put back, writes the template's value, and the envelope the old remedy named is stored as an object (controls). **`flow-double-brace-interpolation`'s value-slot hint** (carry `6092547738` item 2). In a value slot it named `{record.title}`, which the judge refuses. It now reads the author's intent: each `{{ path }}` hole becomes the single-brace token it means, and the hint carries the envelope the judge writes for that string at the string's own position (pass 2's probe, `valueSlotRefusalAt`). Elsewhere the hint is unchanged. `flow-bare-dollar-reference`'s value-slot hint also asks the judge at the position now, so a nested or legacy-shape string gets the spelling that evaluates there. The two #1315 false-positive guards are re-titled; their assertions are unchanged. **The `has()` guard holds for an absent variable** (seat 3's pointer `6094071127`). For a path that may be absent, the refusal named a guard on the 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`. Measured through `AutomationEngine` at `0593be8782`: `has(vars.source.id)` fails too (`No such key: source`). The judge now prints a chain off `vars`, which holds only the bound variables: `has(vars.source) && has(vars.source.id) ? vars.source.id : null`. That answers `null` for an unbound variable, an absent key or intermediate key, a `null` variable and a non-map variable, and the value when bound. `flow-bare-dollar-reference`'s hint carries the judge's words, so it is fixed at the producer. **Texts that said the macros were kept, or "no string form yet"** (carry `6067854904` item 3): `content/docs/automation/flows.mdx` (FROM → TO rows, the literal-data paragraph, the dialect table, the failure modes, the guard row), the judge's and the token module's docblocks, `builtin/template.ts`'s docblock, the `validate-expressions.fields-value-slot.test.ts` comment, the published `service-automation` README *Expressions* section, and the two value-slot `.describe()` strings with the regenerated `builtin-node-config.mdx`. `skills/**` is not touched (#22585). **The ledger.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended: its surface, replacement, reason and acceptance criteria name the macros, their forms and edges, the literal-data positions and the guard. `flow-text-slot-single-brace-refused` is amended where its replacement computed a date through the value-slot spelling this change refuses. `registry.ts` is regenerated (`gen:migration-registry`); it is byte-identical to the generator's output after the merge of `main`. No D2 conversion and no new entry. **Census.** No date macro sits in a value slot in `examples/**` or in `packages/**` source at `3c97f71067`. The remaining hits are filter positions, which keep the dialect, and docs. This repository's own test fixtures and docs that used one are migrated. Cross-repo note: pass 1's census measured 15 date-macro value-slot sites in hotcrm at `c529de2` (8 `{NOW()}`, 3 `{TODAY()}`, 3 `{TODAY() + N}`, 1 `{TODAY() + var}`). They migrate there; hotcrm is not edited. **Changeset.** `.changeset/19939-flow-value-slot-date-macros-refused.md`: `@objectstack/spec` major, `@objectstack/service-automation` major, `@objectstack/lint` patch (pass 1 and pass 2's levels, on the `next` line), with FROM → TO rows and the ADR-0087 `already-registered` disposition. `Clause-②: no (narrowing)` holds on measurement. The new `celDateMacro` / `CelDateMacro` exports stay in the package-internal `flow-template-token.ts`, which no entry re-exports (spec's `exports` has no wildcard subpath). No `api-surface/` or `export-origins/` snapshot moved, and `check:api-surface` / `check:export-origins` exit 0. ## Surface crossings The claim lists the judge, the token module, `builtin/template.ts`, the parity pin, `lint-flow-patterns.ts`, the `.describe()` strings, the step-18 value-slot entry and `registry.ts`, `flows.mdx`, the README, examples and one changeset. These files are outside that list. Each one would have stated something false after this change, or pinned a spelling it refuses: - `service-automation/src/builtin/crud-nodes.ts`, `logic-nodes.ts`: docblock comments only. They said the date macros were kept and reached `interpolate()`. - `spec/src/automation/flow-text-slot-template.ts` (and its test), with the step-18 entry `18.flow-text-slot-single-brace-refused.ts`: the text-slot remedy for a date macro named `assignments: { v: '{TODAY() + 7}' }`, a spelling this change refuses. It now names the CEL envelope. - Tests that pinned the kept spelling: `spec/src/automation/builtin-node-config.test.ts`, and in `service-automation`, `logic-nodes.test.ts`, `value-slot-template-grammar.test.ts` and `assignment-value-envelope.test.ts` (the literal-position engine pins). `value-slot-template-grammar.test.ts` also hosts the engine pin of the guard. No governed surface is touched (`skills/**`, `.claude/**`, `docs/adr/**`, `AGENTS.md`, `CLAUDE.md`). ## Tests and gates (head `3c97f71067`) `3c97f71067` is the final head. It holds the merge of `main` at `d748ae80af` (`5027462a6a`), the regenerated reference (`31f20e025e`), the guard fix (`0593be8782`), then the merge of `main` at `e22315238f`. Both merges went through `os-regen-merge.sh`. `registry.ts` regenerates byte-identical after each, and every entry id on `main`'s registry is present. The build is spec plus the dependency closures of `service-automation` and `lint` (29 turbo tasks, exit 0), rebuilt after each merge. All readings below were taken at `3c97f71067`. The same set was green at `0593be8782` before the second merge. - `@objectstack/spec` local project: 642 files, 19164 passed and 1 todo. Repo project: 54 files, 915 passed. - `@objectstack/service-automation`: 181 files, 2316 passed. - `@objectstack/lint`: 134 files, 6130 passed. - Typecheck exit 0 for spec, lint and service-automation (each package's script, `check:test-typecheck` included). - Gates: `dispatch-gates --commands`, re-derived on the actual diff, gives 117 commands: the 114 at dispatch plus spec `check:generated`, `check:quick-reference-counts` and `check:swallow-census-controls`. All 117 exit 0, and `dispatch-gates --ran` reports 117 derived, 117 run, 0 NOT-MEASURED and 0 UNRUN. - Repo lint, narrowed and proven: - ① The population is `eslint . --no-inline-config` over `eslint.config.mjs`. - ② `--format json` over the 20 changed lintable files reports 20 linted, 0 ignored, 0 errors and 0 warnings. - ③ That config enables no type-aware linting (no `parserOptions.project`, no typed rules, as its own note says), so this diff cannot move a verdict on a file it does not touch. The full `pnpm lint` is CI's. - `main` moved to `f368b7e980` after these readings. Its six new commits share no path with this PR, and `registry.ts` is not among them. The PR's CI runs on the merge ref. ## Ablations Five ablations, one per behaviour: the order's four, plus the guard. Each went through `scripts/ablation-replace.mjs` in WRAP mode under the verify lock, from the committed state `0593be8782`. Each mutation is verified on disk (anchor x1 → x0, blob changed). Each restore is proven blob == HEAD with an empty `git diff HEAD`. The suites under `service-automation` and `lint` resolve `@objectstack/spec` through its built `dist/` (no source alias). So A2, A3 and A5 rebuilt spec on the mutated source. Each confirmed with `ablation-dist-preflight.mjs` that the marker reached `dist/` before reading a result. After the restore, each rebuilt spec again and confirmed with `--absent` that the marker left `dist/`, with `git status --porcelain` empty. - **A1, the refusal.** The judge skips a string carrying a date macro again (the old kept kind). `flow-value-slot-template.test.ts` (src) goes red: 23 failed, 94 passed. Every date-macro refusal, the edges, the kept-spelling leak and the doors' contract pins fail. Restored to blob `e403d51fd5cc`. - **A2, the parity.** `{NOW() ± N}`'s remedy is mutated to land on midnight, `isoDatetime(addDays(today(), N))`. The live parity pin in `crud-fields-value-envelope.test.ts` evaluates the spelling the judge prints and goes red on the written bytes: 2 failed, 57 passed (`{NOW() + 2}`, `{NOW() - 1}`). Example: `expected '2026-03-10T00:00:00.000Z' to be '2026-03-10T09:30:00.000Z'`. Restored to blob `5ca227356df7`. - **A3, the literal-data positions.** `literalPositionLead` answers nothing, which puts back the envelope at the position. The judge test (src) goes red: 5 failed, 112 passed. The engine pins go red: 8 failed, 97 passed, across `assignment-value-envelope.test.ts` and `crud-fields-value-envelope.test.ts`. The controls stay green: the envelope at those positions is stored as an object, and the top-level slot keeps its envelope remedy. Restored to blob `e403d51fd5cc`. - **A4, the double-brace hint.** The old `Use {var}` hint at every position. `lint-flow-patterns.test.ts` (src) goes red: 3 failed, 203 passed, exactly the three value-slot double-brace pins. Both controls stay green. Restored to blob `6366250123ba`. - **A5, the guard.** The guard is put back on the last step alone, `has(vars.source.id) ? …`. The judge test goes red: 5 failed, 112 passed. The engine pins in `value-slot-template-grammar.test.ts` go red: 5 failed, 70 passed, each on evaluation, for example `{source.id}: … No such key: source`. The single-segment `{x}` row stays green, as it should. The lint hint pin goes red: 1 failed, 205 passed. Restored to blob `e403d51fd5cc`. ## Acceptance notes Nothing here is filed. Each item is for the seat: - **Pending release notes this change makes false.** Pass 1's `19939-flow-value-slot-template-dialect-refused.md` lists the date macros as accepted and prints the last-key guard. Pass 2's `19939-flow-value-slot-run-user-refused.md` lists the date macros as still accepted. #22110's `22110-flow-text-slot-double-brace.md` computes a date through the value-slot spelling. This PR's changeset says it supersedes those lines. Correcting them in place is `check:empty-changeset`'s DELIBERATE CORRECTION path, which #22586 carries. - **`record` with no `record` variable.** In a flow CEL expression, `record` reads the variables map itself when the run binds no `record` variable: `buildScope` binds the scope's `record` argument, and `celScope` passes `vars` there. So `has(record.assignee) ? record.assignee : null` returns a variable named `assignee` ("u9" over `{ assignee: 'u9' }`). Measured through `evaluateValueEnvelope` at `0593be8782`; no public door was measured, so it is not filed. The judge's chain guard reads `vars.record` and answers `null` there, as the template did. - **Comments outside the surface that still show a refused spelling:** `packages/lint/src/validate-field-consumers.ts:335` (`fields: { added_date: '{NOW()}' }`, an illustration of a write key) and `packages/spec/src/automation/flow-node-expression-paths.test.ts:130` (a `{token}` string "keeps its 17.x meaning", stale since pass 1). The generated `spec-changes.json` and `docs/protocol-upgrade-guide.md` regenerate at release; no regeneration is owed here. - **`skills/objectstack-automation/SKILL.md`** still teaches the date macros as kept (Tier H, #22585). - **The six rider positions** keep the single-brace dialect. The carry line on #19939 stands. - **PR #22609** (seat 3) edits `lint-flow-patterns.test.ts`'s `valueFlow` fixture in hunks it measured as disjoint from this PR's. Whichever lands later merges `main`. - **`Clause-②`.** The claim line reads `no`; the dispatch prescribes `no (narrowing)`, the same value with its direction arm stated. --- _Generated by [Claude Code](https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f53f14b commit d85615d

24 files changed

Lines changed: 1351 additions & 271 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/service-automation': major
4+
'@objectstack/lint': patch
5+
---
6+
7+
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))`.
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- 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. -->
12+
13+
**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.*`).
14+
15+
**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.
16+
17+
**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.
18+
19+
**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:
20+
21+
- 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;
22+
- 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;
23+
- an offset that is neither a number nor a variable name (`{TODAY() + 3d}`): the template added 0 days.
24+
25+
**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.
26+
27+
**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.
28+
29+
## FROM → TO
30+
31+
| you wrote | write instead | what changes |
32+
|:--|:--|:--|
33+
| `'{TODAY()}'` | `{ dialect: 'cel', source: 'isoDate(today())' }` | nothing |
34+
| `'{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 |
35+
| `'{NOW()}'` | `{ dialect: 'cel', source: 'isoDatetime(now())' }` | nothing |
36+
| `'{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 |
37+
| `'{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 |
38+
| `'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 |
39+
| `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 |
40+
| 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 |
41+
| 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 |
42+
43+
**The one-line fix: write a date macro as `isoDate(…)` / `isoDatetime(…)` in a CEL envelope, with a whole number of days.**
44+
45+
**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.
46+
47+
**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.
48+
49+
### The kit
50+
51+
- **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.
52+
- **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.
53+
- **`@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.
54+
- **`@objectstack/service-automation`'s README.** Its *Expressions* section names the date macros' CEL forms.

‎content/docs/automation/flows.mdx‎

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -265,19 +265,38 @@ for some input, so the rewrite is yours to judge.
265265

266266
| you wrote | write instead | what changes |
267267
|:---|:---|:---|
268-
| `'{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`) |
268+
| `'{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) |
269269
| `'{items.0}'` | `source: 'items[0]'` | an empty list fails the run |
270270
| `'{$error.message}'` | `source: 'vars["$error"].message'` | a `$`-named variable is read through `vars` |
271271
| `'{round(x * 100) / 100}'` | `source: 'round(x * 100) / 100.0'` | CEL divides two integers as integers: keep a decimal operand on every division |
272272
| `'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(…, '')` |
273273
| braces meant literally, `'{"a": 1}'` | `source: "'{\"a\": 1}'"` | a CEL string literal |
274274
| `'{$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` |
275275
| `'{$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` |
276-
277-
One spelling **keeps** its meaning for now, because CEL cannot write it yet:
278-
the date macros (`'{NOW()}'`, `'{TODAY() + 7}'` — CEL's `now()` / `today()` are
279-
timestamps, not the ISO text the macros write, and there is no string form for
280-
one).
276+
| `'{TODAY()}'` | `source: 'isoDate(today())'` | none: `isoDate` writes the `YYYY-MM-DD` text the macro wrote, on the UTC calendar |
277+
| `'{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 |
278+
| `'{NOW()}'` | `source: 'isoDatetime(now())'` | none: `isoDatetime` writes the `YYYY-MM-DDTHH:mm:ss.sssZ` text the macro wrote |
279+
| `'{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 |
280+
| `'{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 |
281+
| `'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 |
282+
283+
No spelling is kept: a value slot reads no `{…}` token at all.
284+
285+
**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
288+
(an `assignments: [{ variable, value }]` array, variables as the config's own
289+
keys), it is stored as the object it spells — so the refusal there names what
290+
does evaluate:
291+
292+
- a string inside an object or list value: build the **whole value** as one
293+
envelope, a CEL map or list literal — `payload: { note: 'for {name}' }` is
294+
`payload: { dialect: 'cel', source: "{'note': 'for ' + name}" }`. CEL holds
295+
every value of one map, and every element of one list, to one type, so where
296+
they mix — a literal beside a computed value included — wrap each one in
297+
`dyn(…)`: `"{'who': dyn(name), 'n': dyn(3)}"`;
298+
- a legacy `assignment` shape: move the node's assignments into the
299+
`assignments` map, where the envelope evaluates.
281300

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

@@ -327,7 +346,7 @@ check.
327346
| `'Deal won: {record.name}'` | `'Deal won: {{ record.name }}'` |
328347
| `'Failed: {$error.message}'` | `'Failed: {{ $error.message }}'` |
329348
| `'Total {amount * 2}'`, `'{round(x)}'` | compute it into a variable (`assignments: { v: { dialect: 'cel', source: 'amount * 2' } }`), then `'Total {{ v }}'` |
330-
| `'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 }}'` |
349+
| `'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 }}'` |
331350
| `'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 |
332351

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

20002018
| Where | Dialect | Write it like | Bindings |
20012019
|:---|:---|:---|:---|
20022020
| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*`, `current_user` |
20032021
| Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above |
20042022
| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, `vars.*` and `current_user` |
2005-
| 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) |
2023+
| 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) |
20062024
| 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`, …) |
20072025
| 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 |
20082026

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

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

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

0 commit comments

Comments
 (0)