Repository navigation
Commit d4680d2
feat(spec): notify title/message are template slots — bare string or tmpl envelope (#22063)
Fixes #22054
Clause-②: yes (narrowing: the template envelope is newly accepted, and a
blank or whitespace bare string is newly refused at `title` / `message`,
with `NotifyConfigParsed` re-shaped; a BREAKING accept-set narrowing,
`@objectstack/spec` `minor` under the launch-window convention, per
contract review `6033083158`)
## What changed
The expression dialect table in
`packages/spec/src/shared/expression.zod.ts` lists notification subjects
and bodies as `template` slots. `NotifyConfigSchema.title` and
`NotifyConfigSchema.message` were `z.string()`, so a notify node written
with `` tmpl`…` `` was refused. This PR follows the triage direction
(the first of the card's two):
- **Spec (`packages/spec/src/automation/io-node-config.zod.ts`).**
`title` and `message` are typed
`TemplateExpressionInputSchema.optional()`, the input every other
`template` slot uses. Both the bare string and the `{ dialect:
'template', source }` envelope parse. The parse normalizes the bare
string to that envelope, so both spellings of one text parse to the same
value.
- The mutual-exclusion rule (`template` against `title`/`message`), the
`templateData` rule and the "needs a content source" rule are unchanged.
- One rule is added, for this slot only. A template envelope on either
key must carry a non-blank `source`. The shared input's envelope arm is
the persistence contract and admits an `ast`-only envelope or a
whitespace `source`. The executor renders `source` only, so without this
rule such a `title` would fail every run and such a `message` would go
out empty.
- **Executor
(`packages/services/service-automation/src/builtin/notify-node.ts`,
declared cross-lane file).** The executor reads `cfg.title?.source` and
`cfg.message?.source` and interpolates them exactly as before. Before
this change it read the slot whole: `interpolate` walks an object key by
key, and `stringifyForTemplate` then serialized it as JSON (H1 reading
below). The descriptor's `title`/`message` descriptions now state the
`{token}` interpolation instead of "sent verbatim". The descriptor keeps
`type: 'string'`: the Studio form edits the bare-string spelling.
- **Every published sentence about the two keys is now true.** The
`.describe()` texts, the schema docblock and the conflict-refusal text
said the text is "sent verbatim". The executor interpolates it, so that
sentence was already false before this PR. They now say which
placeholder spelling the slot's renderer reads: the flow's single-brace
`{token}`.
- Generated: `content/docs/references/automation/io-node-config.mdx`
(`gen:docs`). No other spec artifact moved. `check:generated` reports
all 15 up to date.
## Measurements
**H1: what the executor did with an envelope.** Measured with
`interpolate` and `stringifyForTemplate` from `template.ts`, with
`record = { priority: 'P1', subject: 'Server down' }`:
| input | before (base `d5a14dd5`) | after |
|:---|:---|:---|
| bare `'[{record.priority}] {record.subject}'` | `[P1] Server down` |
`[P1] Server down` (unchanged) |
| `` tmpl`[{record.priority}] {record.subject}` `` through `interpolate`
+ `stringifyForTemplate` | `{"dialect":"template","source":"[P1] Server
down"}` | not reached: the executor reads `source` |
| same envelope, through the whole node | refused at the execute-time
parse: `config.title: Invalid input: expected string, received object` |
delivered `[P1] Server down` |
Ablation of the executor read, with the new spec and the old read
`interpolate(cfg.title ?? '', …)`: all three render pins in
`notify-template-slots.test.ts` go red. The bare string goes red too,
because the parse now hands the executor an envelope for both spellings.
The delivered title was `{"dialect":"template","source":"[won] Deal
Acme"}`. The restore was verified (blob equal to `HEAD`, `git diff HEAD`
empty).
**Premise check.** The card says `defineFlow` refuses the envelope. On
`main` at `d5a14dd5` it does not. `FlowSchema.safeParse` and
`defineFlow` accept a notify node with a `tmpl` title, and
`AutomationEngine.registerFlow` registers it. The refusal comes only at
execute time, from `parseNodeConfig` (`config.title: Invalid input:
expected string, received object`). The flow builds and registers, then
fails every run. The card's core premise holds: the schema disagrees
with the dialect table.
**Pins** (spec `io-node-config.test.ts`, executor
`notify-template-slots.test.ts`):
| value at `title` / `message` | parse | render |
|:---|:---|:---|
| `'[{stage}] Deal {dealName}'` (bare) | ok, normalized to `{ dialect:
'template', source }` | `[won] Deal Acme` |
| `` tmpl`[{stage}] Deal {dealName}` `` / `{ dialect: 'template', source
}` | ok, same value | `[won] Deal Acme` (same text) |
| `42`, `true`, `['a']`, `{ source }`, `{ dialect: 'cel', source }` |
refused, one issue at the key: `invalid_union`, message equal to
`TYPED_EXPRESSION_DIALECT_ONLY.template` | node refused before anything
is sent |
| `''`, `' '` | refused: `invalid_union`, message equal to
`TYPED_EXPRESSION_SOURCE_REQUIRED.template` | n/a |
| `{ dialect: 'template', ast: … }`, `{ dialect: 'template', source: ' '
}` | refused: `custom` at the key, message naming `source` | n/a |
Reverse runs. The new spec pins were run against the base schema file
(restored from `d5a14dd5`, trap-restored, blob verified). Result: 7 red
(the 6 new pins and the updated "accepts every declared key"), 27 green.
Disabling only the new `source` rule turns exactly 1 pin red. A
cross-package type check: writing `cfg.title?.trim()` in the executor
makes `tsc` red with `TS2339 … on type '{ dialect: "template"; source:
string; } | …'`, so `service-automation` reads the rebuilt `.d.ts`.
**H3: the `subject` alias conversion.** No change is needed.
- ``subject: 'X'`` alongside ``title: tmpl`X` `` are structurally
different values, so `flow-node-notify-config-aliases` keeps both. The
strict gate then refuses `subject` with its guidance.
- `subject` alone, as a bare string or an envelope, still renames onto
`title` and parses.
- The guidance's "with DIFFERENT text" is now "with a DIFFERENT value",
which is true in this case as well.
- Nothing is lost silently. No stored pre-17 flow can carry the pair,
because an envelope `title` never parsed before this PR.
**H5: the expression-slot machinery.** Nothing new sees these keys as
expression slots.
- They are not on `FLOW_NODE_EXPRESSION_PATHS`, so the lint
`validateExpression` walk and the registration expression pass do not
visit them.
- The generic `{token}` path walk (`validate-flow-template-paths`)
recurses into objects, so it reads an envelope's `source` as it read the
bare string.
- `authorable-surface`, `liveness` and the strictness ledger record
keys, and the key set is unchanged. All three gates are green with no
artifact moved.
- `check:api-surface` is green with no artifact change.
## Acceptance notes
- **Placeholder spelling.** These two slots render through the flow's
`interpolate()`, so the placeholder is `{record.name}`. A
`{{record.name}}` renders with its outer braces left in (`{Acme}`), for
a bare string and an envelope alike. That was already true for bare
strings. The `.describe()` texts now say it.
- The card's own example (`` tmpl`[{{record.priority}}]
{{record.subject}}` ``) now parses and renders `[{P1}] {Server down}`.
- Three shared texts outside this PR's surface still show `{{var}}` as
the spelling to write: the shared template refusals
`TYPED_EXPRESSION_SOURCE_REQUIRED.template` and
`TYPED_EXPRESSION_DIALECT_ONLY.template`, and the `tmpl` docblock. This
is reported to the seat; it is not changed here.
- **Blank strings: a BREAKING accept-set narrowing.** A blank bare
string (`''` or whitespace-only) at either key was accepted on `main`
and is now refused, by the shared template input's non-blank rule.
- `title: ''` used to parse and then fail every run with "notify: title
is required", so it fails either way, now earlier.
- A whitespace-only `title` passed that guard and was delivered. It is
now refused.
- A blank or whitespace-only `message` was delivered as an empty or
blank body. It is now refused.
- Measured: zero blank notify `title`/`message` values in the 31 in-repo
authoring files and at the objectui pin. objectui's flow inspector
deletes a cleared key (`setAtPath`) only for `''`, so a whitespace-only
Studio entry is stored.
- Graded as the repo graded the same rule in `f81afe3`: `feat(spec)!`,
`Clause-②: yes (narrowing)`, an ADR-0087 `not-required
(no-migration-prescription)` marker with this census, and a **BREAKING**
line, shipped as `minor` under the launch-window convention (contract
review `6033083158`; patch round 1, `9bfb746a35`).
- The parse output also changes: `NotifyConfigSchema.parse(...).title` /
`.message` go from a string to `{ dialect: 'template', source }`, and
`NotifyConfigParsed` with them. The notify executor, the one reader of
parse output in this repo, reads `.source`.
- **Studio form.** The descriptor keeps `type: 'string'` for both keys,
so the Studio form authors the bare string. objectui's
`FlowNodeConfigField` renders a text control with `String(value)`, so a
code-authored envelope would display as `[object Object]` there. That is
outside this repo and is noted for the objectui owner.
- **Not merged with `main`.** `origin/main` gained 2 commits since
`d5a14dd5` (`packages/spec/src/ui/**` and `metadata-protocol`). They
share no file with this diff.
- PR #21974 also edits `notify-node.test.ts`. The new executor pins live
in their own file, `notify-template-slots.test.ts`, so the two PRs do
not conflict there.
## Local verification (final commit `01ef8368e5`)
Every reading below was taken on this branch. `packages/spec` is
byte-identical from `ed7166a5e2` to the final commit `01ef8368e5`. The
only later change is one line in `notify-template-slots.test.ts`.
- `pnpm --filter @objectstack/spec build` (JS and DTS): exit 0.
- `pnpm --filter @objectstack/spec check:generated`: exit 0, all 15
artifacts up to date. `check:api-surface`, `check:authorable-surface`,
`check:docs`, `check:liveness` and `check:strictness-ledger` are among
them.
- `pnpm --filter @objectstack/spec test`: 620 files, 18497 passed, 1
todo, exit 0. Run at `ed7166a5e2`.
- `pnpm --filter @objectstack/spec typecheck`: exit 0.
`check:test-typecheck` holds its ledger unchanged.
- `pnpm --filter @objectstack/service-automation test`: 174 files, 2116
passed, exit 0. Run at `01ef8368e5`.
- `pnpm --filter @objectstack/service-automation typecheck`: exit 0.
- `pnpm --filter @objectstack/lint test`: 120 files, 5638 passed, exit
0. Run at `01ef8368e5`.
- `node scripts/pm/dispatch-gates.mjs --commands`: 111 families, derived
with no paths at `01ef8368e5`. All were run and reconciled with `--ran`:
110 run, 1 NOT MEASURED, 0 unrun.
- NOT MEASURED, reason: `pnpm check:dual-build-cjs-loads` exited 3
(PREREQUISITE NOT MET: it needs every package's `dist/`). It is declared
to CI.
- `check:skill-examples` first exited 3 because the client packages had
no `dist/`. After building `@objectstack/client` and
`@objectstack/client-react` it exited 0 (262 examples type-check).
- ESLint, narrowed to the 4 changed TS files and run at `01ef8368e5`
with `--no-inline-config --format json`.
- Population: `eslint.config.mjs` lints
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (the `.md`/`.mdx` files in this
diff are outside it).
- Count: 4 files, 0 errors, 0 warnings, read from the JSON output.
- Invariance: the config never enables type-aware linting (no
`parserOptions.project`, no `projectService`), so this diff cannot
change any untouched file's verdict.
- The repo-wide `pnpm lint` is left to CI.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent e67ba80 commit d4680d2
7 files changed
Lines changed: 370 additions & 35 deletions
File tree
- .changeset
- content/docs/references/automation
- packages
- qa/dogfood/test
- services/service-automation/src/builtin
- spec/src/automation
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
28 | | - | |
29 | | - | |
30 | | - | |
31 | | - | |
32 | | - | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
| |||
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
98 | | - | |
99 | | - | |
| 98 | + | |
| 99 | + | |
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
| |||
Lines changed: 13 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
597 | 597 | | |
598 | 598 | | |
599 | 599 | | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
600 | 613 | | |
601 | 614 | | |
602 | 615 | | |
| |||
Lines changed: 21 additions & 6 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
179 | 179 | | |
180 | 180 | | |
181 | 181 | | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
182 | 188 | | |
183 | 189 | | |
184 | | - | |
| 190 | + | |
185 | 191 | | |
186 | 192 | | |
187 | 193 | | |
188 | | - | |
| 194 | + | |
189 | 195 | | |
190 | 196 | | |
191 | 197 | | |
| |||
243 | 249 | | |
244 | 250 | | |
245 | 251 | | |
246 | | - | |
247 | | - | |
| 252 | + | |
| 253 | + | |
248 | 254 | | |
249 | 255 | | |
250 | 256 | | |
| |||
256 | 262 | | |
257 | 263 | | |
258 | 264 | | |
259 | | - | |
260 | | - | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
261 | 276 | | |
262 | 277 | | |
263 | 278 | | |
| |||
0 commit comments