Commit fb33767
fix(example-todo): move both
Fixes #19206
`Clause-②: no`
## What was wrong
Both `loop` nodes in `examples/app-todo/src/flows/task.flow.ts` declared
`collection` + `iteratorVariable` and **no `config.body`**. That is the
**legacy flat-graph** form:
`packages/services/service-automation/src/builtin/loop-node.ts` opens
with `if (raw.body == null)`, sets `$loopItems` / `$loopIndex` and
returns **without binding `iteratorVariable`**. The per-item steps were
wired as ordinary nodes *after* the container, so every
`{currentTask.…}` token in them resolved to nothing.
Measured on a real engine in this PR's control test (`CONTROL: the
pre-fix body-less shape still dies at update_priority`):
```
update_record: refusing to run — 1 filter condition(s) resolved to nothing
and were dropped from the query: `{currentTask.id}` (at id).
```
`notify_owner` sits one node **behind** that refusal, so **no escalation
notice was sent at all** — and the control asserts that too (`emits` has
length 0). `task_reminder` had the identical shape.
## The repair — established from the schema and the engine
The authority is `LoopConfigSchema` / `FlowRegionSchema` in
`packages/spec/src/automation/control-flow.zod.ts` (ADR-0031
representation **(B)**, nested sub-structure) plus the executor's
structured branch:
- `config.body` is a `FlowRegionSchema`: `{ nodes: min 1, edges: default
[] }`, **single-entry / single-exit / acyclic** (`analyzeRegion`),
rejected at `registerFlow()` via `validateControlFlow` when it is not.
- The executor binds `iteratorVariable` in the **enclosing** scope per
item and runs the region through `engine.runRegion`; the loop node's
**ordinary out-edge is the after-loop continuation**, which is why `e3`
now goes `loop → end`.
Each body carries one `try_catch` (`flow-loop-body-uncontained`, the
shape the lint rule's own hint prescribes, and the form
`examples/app-showcase` already demonstrates), with the minimal handler
— one bare `assignment` node. On `overdue_escalation` **one**
`try_catch` wraps **both** per-item steps rather than one each: the
notice announces the escalation the update performs, so a row whose
update was refused must not be told its priority was raised.
`maxIterations: 200` states, on the iterating side, the same bound the
`get_record` step's `limit: 200` already imposes.
## What the notice actually renders — the reading this card unblocks
With the loop body executing, the escalation notice is sent, and
(composed with the `days_overdue` formula that landed in #19205 /
#18584) it interpolates. Read off the captured `messaging.emit()`
payload in a real run:
```
title: URGENT: task overdue — Alpha
body: Due 2026-09-16, 4 day(s) overdue.
```
Three seeded rows render three distinct notices with their own owner,
subject, due date and day count — no leaked binding, no blank hole.
## Tests
`examples/app-todo/test/loop-body-iteration.test.ts` — drives the
**real** flows on a real `AutomationEngine` over a real sqlite-wasm
database holding the app's real `todo_task`. The only double is the
`messaging` service, and it is a **capture**, not a simulation:
capturing `emit()` is how the rendered title/body becomes readable at
all.
1. ESCALATION — run succeeds, the seeded row's `priority` is `urgent`
(so the body's first node matched the right row), exactly one notice,
and its rendered body carries the real day count and no unresolved
brace.
2. EVERY item is processed, each with its own values (a leaked binding
would show as three identical notices).
3. CONTAINMENT — one row with an unresolvable recipient fails inside the
body's `try_catch` and the sweep still reaches the rows after it.
4. REMINDER — the second loop in the file binds its iterator too.
5. CONTROL — the pre-fix body-less shape, rebuilt from the real flow,
still dies at `update_priority` and notifies nobody.
`test/overdue-escalation-days-overdue.test.ts` (the #18584 pin) keeps
pinning: its `node()` helper now walks the ADR-0031 region slots,
because the nodes it inspects moved into the loop body. The question it
asks is about those nodes' **config**, which nesting does not change.
## Evidence
- `pnpm --filter @objectstack/example-todo exec vitest run` — **exit
0**, 7 files / 238 tests passed.
- `pnpm --filter @objectstack/example-todo typecheck` — the package's
own script is `tsc --noEmit` (read from its `package.json`, one leg) —
**exit 0**.
- `pnpm --filter '@objectstack/example-todo^...' build` — **exit 0**.
- Gate families derived from the real change set with `node
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`: **37 derived, 37 run, 0 NOT-MEASURED, 0 UNRUN** (`--ran`
reconciliation with exit codes recorded before any pipe).
`check:dual-build-cjs-loads` first answered **exit 3 = PREREQUISITE NOT
MET = not measured**; after the missing `dist/` was present it was
re-run and answered **exit 0**.
- `pnpm lint` (repo-wide `eslint . --no-inline-config`, the whole
population, not a narrowed run) — **exit 0** at `c6ca194c2`.
## Changeset
`skip-changeset`: `examples/app-todo/package.json` declares `"private":
true` and the diff touches nothing else, so this publishes nothing from
any released package.
## Scope
`examples/app-todo/` only. No engine change was needed — and none was
made: the container the schema already declares is what binds the
iterator. `packages/spec/**` and `content/docs/releases/**` are
untouched.
## Acceptance notes
- `noted, not filed:` the two scheduled sweeps read date macros
(`{tomorrow}`, `{3_days_ago}`) that `interpolateFilter` deliberately
passes through verbatim for the data layer to resolve. Working as
designed here; recorded only because the same brace syntax means
something different one line away in the same config. Not a defect, not
filed.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_
---------
Co-authored-by: Claude <noreply@anthropic.com>loop bodies into config.body so currentTask binds and the escalation notice is sent (#19262)1 parent 1739f71 commit fb33767
3 files changed
Lines changed: 419 additions & 38 deletions
File tree
- examples/app-todo
- src/flows
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
58 | 58 | | |
59 | 59 | | |
60 | 60 | | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
61 | 71 | | |
62 | | - | |
63 | | - | |
64 | | - | |
65 | | - | |
66 | | - | |
67 | | - | |
68 | | - | |
69 | | - | |
70 | 72 | | |
71 | | - | |
72 | | - | |
73 | | - | |
74 | | - | |
75 | | - | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
76 | 124 | | |
77 | 125 | | |
78 | 126 | | |
| |||
81 | 129 | | |
82 | 130 | | |
83 | 131 | | |
84 | | - | |
85 | | - | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
86 | 135 | | |
87 | 136 | | |
88 | 137 | | |
| |||
117 | 166 | | |
118 | 167 | | |
119 | 168 | | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
120 | 177 | | |
121 | | - | |
122 | | - | |
123 | | - | |
124 | | - | |
125 | 178 | | |
126 | | - | |
127 | | - | |
128 | | - | |
129 | | - | |
130 | | - | |
131 | | - | |
132 | | - | |
133 | | - | |
134 | | - | |
135 | | - | |
136 | | - | |
137 | | - | |
138 | | - | |
139 | | - | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
140 | 241 | | |
141 | 242 | | |
142 | 243 | | |
| |||
145 | 246 | | |
146 | 247 | | |
147 | 248 | | |
148 | | - | |
149 | | - | |
150 | | - | |
| 249 | + | |
| 250 | + | |
151 | 251 | | |
152 | 252 | | |
153 | 253 | | |
| |||
0 commit comments