Description
Path: P2 | platform-core(examples/app-todo flows) | 北极星「优先级」4
Surfaced by the os-dev delivering #18584 (PR #19205) as an out_of_scope_findings entry, and filed here by the domain:services seat — dev agents report findings with dedupe words; they ⛔ do not file. ⛔ Ungraded and unassigned: type and priority are the triage seat's.
🔴 This falsifies the SYMPTOM described on #18584, while leaving that card's defect real. #18584 says the escalation notice renders 「Due , day(s) overdue.」 — a blank interpolation. It does not: no escalation notice is sent at all, because the run dies one node earlier.
Mechanism
Both loop nodes in examples/app-todo/src/flows/task.flow.ts declare a config with collection and iteratorVariable and no config.body:
examples/app-todo/src/flows/task.flow.ts:61-62
id: 'loop_tasks', type: 'loop', label: 'Loop Through Tasks',
config: { collection: '{tasksToRemind}', iteratorVariable: 'currentTask' },
examples/app-todo/src/flows/task.flow.ts:120-121
id: 'loop_overdue', type: 'loop', label: 'Loop Through Overdue Tasks',
config: { collection: '{overdueTasks}', iteratorVariable: 'currentTask' },
A body-less loop takes the legacy flat-graph path. packages/services/service-automation/src/builtin/loop-node.ts says so in its own docblock, verbatim:
Back-compat: a
loopnode with noconfig.bodykeeps the legacy …
On that path the node sets $loopItems / $loopIndex and returns without binding iteratorVariable ⇒ currentTask is never bound, and every {currentTask.X} token downstream resolves to nothing.
Observed on a real run — ⛔ driven, not read
The dev drove the app's real flow rather than reasoning about it:
get_overdue_tasks success selected: 1
loop_overdue success
update_priority FAILURE refusing to run - 1 filter condition(s) resolved to
nothing: {currentTask.id} (at id)
⇒ the run dies at update_priority, which is one node BEFORE notify_owner. ⭐ The failure is loud (the update node refuses rather than matching everything), which is why this has not corrupted data — but it also means the notify step is unreachable, so the day-count interpolation on #18584 never renders at all today.
Scope — wider than one token, wider than one flow
- Affects every
{currentTask.X}token, ⛔ not onlydays_overdue. task_reminder, declared two flows above in the same file, has the identical shape (loop_tasks, same body-less config) ⇒ two flows, one defect.
Repair form — pinned, ⛔ not open
Move the loop's downstream nodes into a config.body region with a per-iteration try_catch (the flow-loop-body-uncontained shape). Worked example in-tree: examples/app-showcase/src/automation/flows/index.ts (its loop nodes at ~:901 and ~:1091 are the body-ful form).
Why it was ⛔ not ridden into PR #19205
Different mechanism, different repair, and it touches both flows in the file. #18584's ruling scopes that card to the days_overdue field. ⭐ The ruled repair there is still correct and still necessary — it is simply not sufficient for a notify-step pin, which is why that PR's acceptance uses the ruling's other named pin site (the record projection the loop iterates). ⇒ these two cards compose; ⛔ neither supersedes the other.
Verified by the filing seat
Re-read on origin/main before filing, ⛔ not taken from the report: both loop nodes' body-less config at the lines quoted, and loop-node.ts's back-compat sentence.
Dedupe words
loop body config.body · iteratorVariable never bound · legacy flat-graph loop · overdue_escalation currentTask unbound · flow-loop-body-uncontained
Related: #18584 / PR #19205 (the days_overdue field, which this does not block) · examples/app-showcase loop nodes (the worked body-ful form).
Generated by Claude Code
Activity
huangyiirene commentedon Sep 20, 2026
Claim: PM loop round R5⚠️ Stated plainly: that means the nearest firing control is the corpus, ⛔ not the directory — the same scan does see held files (
Session:
session_01AhQASwqJr2Z7XfGWUdvnbFBranch:
claude/issue-19206-todo-loop-bodyWorktree:
objectstack-issue-19206Domain:
domain:servicesSeat:
domain:services#1File surface:
examples/app-todo/(stop on breach; explain in the report)Container & model: S,
mode:subagent,model: opus— quoting THIS act'snode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --tier examples/app-todo/src/flows/task.flow.ts, derived at tree7d0f911da: 「no path-derived mandate: the surface hits none of the 3 declared glob(s)」 ⇒ PM judgment, floor sonnet · default opus · ceiling fable.Clause-②: no
Thread-read: 5748247948
Serial constraints cleared: none. Two channels, taken in THIS act. (1) All 22 open PRs enumerated, 314 changed-file rows:
examples/app-todo/src/flows/task.flow.tsheld by 0, andexamples/**has 0 held rows of any kind.packages/spec65 rows,packages/cli31,packages/qa23,content/docs18,packages/plugins6), so the instrument reads; but ⛔ there is no heldexamples/row to prove the predicate fires on THIS prefix. Recorded as a weaker control rather than dressed up as a strong one. (2)git ls-remote --heads origin 'refs/heads/claude/issue-19206*'⇒ 0 of 1148 heads (control: the listing answers 1148). Lane hot-file serial queue is empty.North Star clause 3 — why this p2 is taken
Open P0/P1 re-read in this act: p0 = 4 · p1 = 46 ⇒ clause 3 is LIVE, and it bars p2/p3 工具卡 / 契约卫生卡 — ⛔ not all p2/p3. This card is a runtime product defect: both
loopnodes declarecollection+iteratorVariablewith noconfig.body, so a shipped example app's flow dies a node early and the escalation notice is never sent. ⛔ Neither tooling nor contract hygiene ⇒ not barred.5748247948says this card 「is re-triage input for #18584 and should be read before that card is dispatched」. Read in this act: #18584 is alreadyclosed/completed(2026-09-19T16:42:01Z), carryingdomain:services+priority:p3and nopm:*state or assignee. It was dispatched, delivered and landed this shift — PR #19205, commit10a052c6donorigin/main.⇒ The re-triage input arrived after the thing it was meant to gate. ⛔ This seat does not re-grade or re-open #18584 — that is triage's, and the fact is carried to it, ⛔ not acted on.
⭐ And the landed #18584 fix is ⛔ not invalidated by this card, which is worth stating because the grading comment's wording could be read that way. #18584 added a
days_overdueformula field that was genuinely absent; #19206 is why the notice carrying it is never sent. They compose: with both fixed the notice is sent and interpolates. What the grading comment correctly falsifies is the symptom #18584 was reported from (a blank interpolation in a notice that in fact never arrives), ⛔ not the repair.Generated by Claude Code
huangyiirene commentedon Sep 20, 2026
os-dev-report
{ "issue": 19206, "status": "done", "branch": "claude/issue-19206-todo-loop-body", "pr": "https://github.com/objectstack-ai/objectstack/pull/19262", "session": "session_01AhQASwqJr2Z7XfGWUdvnbF", "premise_still_valid": true, "summary": "Both `loop` nodes in examples/app-todo/src/flows/task.flow.ts now own a `config.body` region, so the ADR-0031 structured container binds `currentTask` per item instead of taking the legacy flat-graph path that bound nothing. Each body carries one `try_catch` with the minimal bare-`assignment` handler (the `flow-loop-body-uncontained` shape); on overdue_escalation ONE try_catch wraps both per-item steps, because the notice announces the escalation the update performs. The shape was established from LoopConfigSchema / FlowRegionSchema in packages/spec/src/automation/control-flow.zod.ts and from loop-node.ts's structured branch, not by copying another flow. No engine change was needed or made. The #18584 pin test kept pinning: its node() helper now walks the region slots, because the nodes it inspects moved into the body.", "tests": "pnpm --filter @objectstack/example-todo exec vitest run --maxWorkers=2 :: exit 0 (7 files / 238 tests passed, under scripts/pm/os-verify-lock.sh, VERDICT command-exit 0). pnpm --filter @objectstack/example-todo typecheck :: exit 0 (the package's own script, read from its package.json, is `tsc --noEmit` - ONE leg, not three). pnpm --filter '@objectstack/example-todo^...' build :: exit 0. pnpm lint (repo-wide `eslint . --no-inline-config`, whole population, not narrowed) :: exit 0 at c6ca194c2. Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 37 families from the real change set; all 37 run with $? captured BEFORE any pipe; `--ran` reconciliation reads '37 derived, 37 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero - all 37 recorded an exit code and none of them is 3)'. check:dual-build-cjs-loads first answered exit 3 = PREREQUISITE NOT MET ('this gate reads built output, and some package has no dist/. Run `pnpm build` first. This is NOT a pass: nothing was measured.') - re-run once the dist was present :: exit 0. CONTROL/ablation: no dist-level byte mutation was performed; the positive control is a permanent suite leg that rebuilds the pre-fix body-less shape FROM the real flow (delete config.body, re-wire the flat edges) and runs it on the same real engine - it fails with the card's exact refusal, 'update_record: refusing to run - 1 filter condition(s) resolved to nothing ... {currentTask.id} (at id)', and emits length 0. Without the fix the same harness therefore goes red, which is what makes the green legs readable.", "mcp_calls": "0 - no MCP GitHub tool was called; every GitHub read and write went through the REST proxy with curl.", "api_writes": "3 REST writes: POST /repos/objectstack-ai/objectstack/pulls (draft PR 19262), POST /repos/objectstack-ai/objectstack/issues/19262/labels (skip-changeset, via scripts/pm/label-write.mjs, read back: size/m + skip-changeset, MATCHES the target), POST /repos/objectstack-ai/objectstack/issues/19206/comments (this report). Plus 2 `git push` (empty-branch routing probe, then the work). No assignee write, no pm:* label.", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: the two scheduled sweeps read date macros ({tomorrow}, {3_days_ago}) in their get_record filters; interpolateFilter passes a recognised macro through verbatim for the ObjectQL layer to resolve, so this is working as designed and not a defect. Recorded only because the identical brace syntax one line away is a flow-variable template. Carrier: this PR's acceptance notes.", "noted, not filed: the sweeps' get_record steps declare no orderBy, so row order is the driver's; the new suite therefore compares multi-row renderings sorted. Not a defect of this card and not filed - the projection is deliberately unnarrowed (#18584 pins that). Carrier: none." ] }Generated by Claude Code
huangyiirene commentedon Sep 20, 2026
✅ ACCEPT — PR #19262, reviewed against the tree and the API, ⛔ not against the report
domain:servicesseat ·session_01AhQASwqJr2Z7XfGWUdvnbF·Seat: domain:services#1· written 2026-09-20T07:59Z. Readings pinned to headc6ca194c2.Shape and scope
draft ✅ · base
main✅ · body first lineFixes #19206✅ ·Clause-②: noat line start ✅. Closing-keyword two-read by machine over the whole body: exactly one hit, this card;#18584and#19205appear with no verb. 3 files, all underexamples/app-todo/— 🔴 zeropackages/spec, zerocontent/docs/releases/**, nothing unrelated.skip-changesetverified rather than accepted:examples/app-todo/package.jsondeclares"private": true⇒ unpublished ⇒ no changeset owed, and the label is the repo's sanctioned spelling for that case (⛔ not an empty changeset).The root cause — re-derived from the engine, ⛔ not read off the report
packages/services/service-automation/src/builtin/loop-node.tsonorigin/main::72if (raw.body == null) {→:77sets$loopItemsand falls through, the legacy flat-graph path; the structured branch at:89/:127is the only one thatvariables.set(iteratorVariable, collection[i])and reachesengine.runRegionat:143. ⇒ with noconfig.body,iteratorVariableis never bound, exactly as the card says. The repair's authority (LoopConfigSchema/FlowRegionSchema, ADR-0031 representation (B)) was established by the dev from the schema and the executor, ⛔ not by copying a neighbouring flow.⭐ The evidence that makes the green readable
The suite carries a permanent CONTROL leg that rebuilds the pre-fix shape from the real flow object (deep-copy,
delete loop.config.body, re-wire the flat edges) and runs it on the same real engine — read attest/loop-body-iteration.test.ts:214. It asserts the card's exact refusal (update_record: refusing to run,{currentTask.id}) andexpect(emits).toHaveLength(0). ⇒ without the fix this harness goes red, which is what licenses reading its green. ⛔ This is not an ablation run once and discarded; it is a leg that keeps failing if the defect returns.⭐ The #18584 pin was measured red-before too — it failed against the fix until its
node()helper was taught the ADR-0031 region slots ⇒ that leg is not vacuous either.⭐ The reading this card was for: with the loop executing, the escalation notice is actually sent, and composed with #18584's landed
days_overdueit rendersDue 2026-09-16, 4 day(s) overdue.— every hole filled. That observable did not exist before this PR.CI
22 success · 6 skipped · 4 in_progress · 0 failure, all pinned to
c6ca194c2. Converging; ⛔ no flip to ready untilLint & Repo Gatesand both type-check jobs readconclusion: successon the then-current head and the full set is green.⭐ The dispatch order was falsified on one point, and that is the correct outcome
The order said the typecheck leg count must be read from the package rather than assumed. The dev did:
@objectstack/example-todo's own script istsc --noEmit— ONE leg. ⇒ together with@objectstack/plugin-approvals's three legs (measured on the sibling card this round), this seat has now observed 1, 2 and 3 legs in three packages. ⭐ That closes correction 13 from both sides: the three-leg spelling this seat once asserted universally is real for some packages and false as a repo-wide constant. Naming the command instead of the answer is what let the dev get it right without the order being right.Generated by Claude Code
huangyiirene commentedon Sep 20, 2026
✅ LANDED on
origin/main—fb33767cf, half-state cleared in one writedomain:servicesseat ·session_01AhQASwqJr2Z7XfGWUdvnbF·Seat: domain:services#1· written 2026-09-20T09:00Z.origin/mainafter a refspec fetchfb33767cf—fix(example-todo): move both loop bodies into config.body so currentTask binds and the escalation notice is sent (#19262)task.flow.ts:79and:185both carrybody: {⇒ both loop nodes, ⛔ not one1739f7187) ⇒ squashed through the merge queue, ⛔ not a direct mergemerged: true,merged_at2026-09-20T08:53:11Z; queued 2026-09-20T08:28:08ZHalf-state cleared in one
label-write.mjscall (exit 0, read-back MATCHES):pm:dispatchedremoved, assigneehuangyiireneremoved. ⛔ No silent assignee drop.bug/priority:p2/domain:servicesleft in place — classification is triage's.⭐ What this landing actually bought, stated once
Before it, the overdue sweep died at
update_prioritywith{currentTask.id}resolving to nothing and no escalation notice was sent at all. After it, composed with #18584'sdays_overdue(landed10a052c6d), the notice is sent and interpolates —Due 2026-09-16, 4 day(s) overdue.⇒ the symptom #18584 was originally reported from is only now observable, which is what the delivery's CONTROL leg pins in perpetuity.Generated by Claude Code
fix(example-todo): move both `loop` bodies into `config.body` so `cur…