Skip to content

[finding] examples/app-todo: both loop nodes declare no config.body, so currentTask is never bound — the overdue flow dies at update_priority and NO escalation notice is sent (task_reminder has the identical shape) #19206

Closed

Description

@huangyiirene

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 loop node with no config.body keeps 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 only days_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. ⚠️ The runtime trace above is the dev's driven reading and is ⛔ not re-driven here; it is reported as measured by it.

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

  1. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 19, 2026
  2. self-assigned this
    on Sep 20, 2026
  3. huangyiirene commented on Sep 20, 2026

    @huangyiirene
    CollaboratorAuthor

    Claim: PM loop round R5
    Session: session_01AhQASwqJr2Z7XfGWUdvnbF
    Branch: claude/issue-19206-todo-loop-body
    Worktree: objectstack-issue-19206
    Domain: domain:services
    Seat: domain:services#1
    File surface: examples/app-todo/ (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: opus — quoting THIS act's node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --tier examples/app-todo/src/flows/task.flow.ts, derived at tree 7d0f911da: 「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.ts held by 0, and examples/** has 0 held rows of any kind. ⚠️ Stated plainly: that means the nearest firing control is the corpus, ⛔ not the directory — the same scan does see held files (packages/spec 65 rows, packages/cli 31, packages/qa 23, content/docs 18, packages/plugins 6), so the instrument reads; but ⛔ there is no held examples/ 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 loop nodes declare collection + iteratorVariable with no config.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.

    ⚠️ A stale premise in the grading comment, corrected here rather than acted on silently

    5748247948 says this card 「is re-triage input for #18584 and should be read before that card is dispatched」. Read in this act: #18584 is already closed / completed (2026-09-19T16:42:01Z), carrying domain:services + priority:p3 and no pm:* state or assignee. It was dispatched, delivered and landed this shift — PR #19205, commit 10a052c6d on origin/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_overdue formula 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

  4. huangyiirene commented on Sep 20, 2026

    @huangyiirene
    CollaboratorAuthor

    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

  5. huangyiirene commented on Sep 20, 2026

    @huangyiirene
    CollaboratorAuthor

    ✅ ACCEPT — PR #19262, reviewed against the tree and the API, ⛔ not against the report

    domain:services seat · session_01AhQASwqJr2Z7XfGWUdvnbF · Seat: domain:services#1 · written 2026-09-20T07:59Z. Readings pinned to head c6ca194c2.

    Shape and scope

    draft ✅ · base main ✅ · body first line Fixes #19206 ✅ · Clause-②: no at line start ✅. Closing-keyword two-read by machine over the whole body: exactly one hit, this card; #18584 and #19205 appear with no verb. 3 files, all under examples/app-todo/ — 🔴 zero packages/spec, zero content/docs/releases/**, nothing unrelated.

    skip-changeset verified rather than accepted: examples/app-todo/package.json declares "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.ts on origin/main: :72 if (raw.body == null) { → :77 sets $loopItems and falls through, the legacy flat-graph path; the structured branch at :89/:127 is the only one that variables.set(iteratorVariable, collection[i]) and reaches engine.runRegion at :143. ⇒ with no config.body, iteratorVariable is 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 at test/loop-body-iteration.test.ts:214. It asserts the card's exact refusal (update_record: refusing to run, {currentTask.id}) and expect(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_overdue it renders Due 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 until Lint & Repo Gates and both type-check jobs read conclusion: success on 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 is tsc --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

  6. removed their assignment
    on Sep 20, 2026
  7. huangyiirene commented on Sep 20, 2026

    @huangyiirene
    CollaboratorAuthor

    ✅ LANDED on origin/main — fb33767cf, half-state cleared in one write

    domain:services seat · session_01AhQASwqJr2Z7XfGWUdvnbF · Seat: domain:services#1 · written 2026-09-20T09:00Z.

    reading result
    origin/main after a refspec fetch fb33767cf — fix(example-todo): move both loop bodies into config.body so currentTask binds and the escalation notice is sent (#19262)
    the change is actually there task.flow.ts:79 and :185 both carry body: { ⇒ both loop nodes, ⛔ not one
    parent count 1 (1739f7187) ⇒ squashed through the merge queue, ⛔ not a direct merge
    PR merged: true, merged_at 2026-09-20T08:53:11Z; queued 2026-09-20T08:28:08Z

    Half-state cleared in one label-write.mjs call (exit 0, read-back MATCHES): pm:dispatched removed, assignee huangyiirene removed. ⛔ No silent assignee drop. bug / priority:p2 / domain:services left in place — classification is triage's.

    ⭐ What this landing actually bought, stated once

    Before it, the overdue sweep died at update_priority with {currentTask.id} resolving to nothing and no escalation notice was sent at all. After it, composed with #18584's days_overdue (landed 10a052c6d), 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

  8. added a commit that references this issue on Sep 28, 2026
    fb33767
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions