Skip to content

fix(service-automation): scope a map node's progress state to one execution of its collection - #15648

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-15616-map-loop-iteration-state
Sep 5, 2026
Merged

os-warren merged 2 commits into
mainfrom
claude/issue-15616-map-loop-iteration-state

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #15616

A map node inside a loop body ran its collection on the first iteration only. Iterations 2..n ran nothing, the map step reported success on every one of them, and the run finished completed.

Mechanism

map tracks its progress through the collection in the flow variable nodeId.$mapState, and wrote it into the flow's shared variable scope without ever removing it. A loop body region runs in that same scope by construction — runRegion is handed the caller's Map deliberately, because the iterator variable and the body's mutations have to stay visible to the rest of the flow. So the state written by iteration 1 was still there when iteration 2 entered the map: it read back started === collection.length, correctly concluded there was nothing left to start, and returned.

The lifetime was the defect, not the key. $mapState was scoped to the run; it should be scoped to one execution of the collection.

The fix

One line of behaviour: the terminal path now deletes the state key instead of re-setting it.

The durable-pause path is deliberately untouched, and that is the load-bearing half of this PR. The card's own scope note warned that "delete the state key when the collection is exhausted" was the obvious candidate and was NOT MEASURED, because a map resumed mid-collection depends on that state surviving. That is established here rather than assumed:

  • map writes $mapState in two places — the suspend arm (map-node.ts, before returning suspend: true) and the terminal arm. Only the terminal one changes.
  • On suspend the engine snapshots the whole scope (Object.fromEntries(variables)) into the SuspendedRun. On resume, resumeInternal rebuilds the scope with new Map(Object.entries(run.variables)) — a fresh map from that snapshot. So the suspend-time write is the only write to this key a resume can ever read, and no terminal-path change can reach it.
  • The one reader outside map-node.ts (resume-authority-gate.test.ts) reads the key off a suspended run's snapshot, so it is on the same side of that line.

A pausing map is in any case unreachable from inside a loop body: runRegion converts a durable pause inside a structured region into an error. The resume path is live for a top-level map, which is what the new resume pin exercises.

Tests

New file map-in-loop-iteration-state.test.ts — four pins on the real AutomationEngine, no mock of the mechanism:

  1. the card's measurement — 5 iterations x 2 items produces 10 child runs (it produced 2);
  2. the run is completed with summary.failed === 0 and the work happened — pinning why failed could never have been the instrument that caught this;
  3. each iteration collects a fresh result set, and nodeId.$mapState is absent from the scope after the map completes (the mechanism asserted directly, not inferred from the child-run count);
  4. the resume half: $mapState is present in the suspend snapshot with started: 1, and driving the pauses through runs every item exactly once.

Both pins were mutated to confirm they discriminate, restoring from HEAD each time and verifying the restore by git diff HEAD plus a blob-hash comparison:

mutation result
terminal delete reverted to set (the pre-fix spelling) all 4 pins red
unconditional delete — the suspend arm deletes too, i.e. the naive fix the card warned about the three loop pins stay green; only the resume pin fails

That second row is the point. The obvious fix makes the reported symptom go away and silently breaks resume, and the resume pin is the only thing standing between the two.

Census — the second deliverable

Searched for the predicate (executors that persist state under a node-scoped key in the shared scope) rather than recalling which ones do. Every variables.set / variables.delete in every builtin, every key expression derived from node.id, module-scope mutable state, writes into the shared context, and every registerNodeExecutor in the repo — including the two in plugin-approvals, which are outside this package.

map is the only one. Every other scope write is an author-named output variable (crud, screen, subflow, logic, try_catch's errorVariable) or a loop's iterator/index binding — written each time, never read back as progress. wait and the approval nodes only read the scope; parallel never touches it. The zero has a firing control: the same patterns hit map's seven writes, including the defective one, and hit wait's and the approval nodes' reads.

⇒ No second instance of the identical mechanism, so nothing beyond map is changed here.

Found and NOT fixed

Gates

pnpm lint (full repo) exit 0; package test 108 files / 1294 tests and typecheck both exit 0, re-run at the final commit ac3079c43. ADR-0087 via the real invocation node scripts/check-adr-0087-registration.mjs --base origin/main --head ac3079c43 exit 0 (1 non-breaking changeset(s) seen), plus its --self-test. The gate set was re-derived from the actual changed files with scripts/pm/dispatch-gates.mjs; the derived families were run and each zero was read off the gate's own verdict line with the exit code captured by redirect, never through a pipe.


🤖 Generated with Claude Code

https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y


Generated by Claude Code

…llection

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…xecution of its collection

`map` tracks progress through its collection in the flow variable
`<nodeId>.$mapState` and wrote it into the flow's SHARED variable scope
without ever removing it. A `loop` body region runs in that same scope by
construction, so the state written by iteration 1 was still there when
iteration 2 entered the map: it read back `started === collection.length`,
concluded there was nothing left to start, and returned success.

Measured on the real engine: 5 iterations x 2 items produced 2 child runs
instead of 10, the map step reported `success` on all five iterations, and
the run finished `completed` with `failed = 0` — silent partial work,
invisible to the very run-level counter built to expose that class.

The state key is now removed once the collection is exhausted, making its
lifetime one execution of the collection rather than the enclosing scope's.

The durable-pause path is deliberately untouched: the write made before
returning `suspend: true` is the mechanism a resume depends on, because
`resumeInternal` rebuilds the scope from the snapshot taken at that suspend
and can never see a later write. Only the terminal path clears the key. A
test pins that half — an unconditional delete leaves the loop assertions
green and fails only the resume pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1be26b0de27b2c45d208a20edffefbac1e4892c2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 826e703f56407a720d02b8ed03f1bf257a0e4488 — the merge of head ac3079c437e48e2b8bc5aa57286a31454b739e67 into base 1be26b0de27b2c45d208a20edffefbac1e4892c2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 826e703f56407a720d02b8ed03f1bf257a0e4488 && git checkout 826e703f56407a720d02b8ed03f1bf257a0e4488
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1be26b0de27b2c45d208a20edffefbac1e4892c2 ac3079c437e48e2b8bc5aa57286a31454b739e67 && git checkout -B drift-repro 1be26b0de27b2c45d208a20edffefbac1e4892c2 && git merge --no-ff ac3079c437e48e2b8bc5aa57286a31454b739e67

node scripts/docs-audit/affected-docs.mjs --json 1be26b0de27b2c45d208a20edffefbac1e4892c2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Clause-② contract review — PR #15648 (card #15616) · PASS

Tier line. CONTRACT_REVIEW_TIER = claude-fable-5-1 (scripts/pm/dispatch-gates.mjs:8659). This review's serving-model evidence is (a) the dispatching PM's attestation that the Agent call carried an explicit model: fable override, and (b) this reviewer's own system-prompt identity, claude-fable-5-1. No session-level field corroborates a per-subagent override — get_session describes the parent session, which reports claude-opus-5 after a user switch — so this is stated as "override + self-report", not as a match against an auditable field. (The first pass of this review stopped at the fuse for exactly that reason; the PM corrected the fuse's source of truth.)

Reviewed at head ac3079c43 (merge-base d30ccb9bd), in a detached worktree; no push, no branch touched. Head sha / file count / blob 0f4c526d0 / ADR-0087 gate were already verified by the PM and are not re-done here.

What was driven (not read)

Single pin file via the package's own vitest (the PM already re-certified the 108-file package union; this round is discrimination only). Restores by git checkout HEAD -- then git hash-object compared to 0f4c526d077cbc3d8e369e22a8ec0a71ba06949f; an empty hash was wired as FAILURE. All three restores matched; git diff HEAD empty after each.

run pin 1 (10 child runs) pin 2 (failed=0 + 10) pin 3 (fresh results, key absent) pin 4 (resume)
head, unmutated ✓ ✓ ✓ ✓
A — terminal delete → set (the pre-fix spelling) ✗ [ 'r1:a', 'r1:b' ] ≠ 10 ✗ length 2 ✗ results re-read + key present ✗
B — unconditional delete: suspend arm set → delete (the naive fix) ✓ ✓ ✓ ✗ only — expected undefined to match { started: 1 }
C (mine) — delete on ENTRY before the read, terminal delete kept ✓ ✓ ✓ ✗ only — expected [ 'a', 'a', 'a' ] to deeply equal [ 'a', 'b', 'c' ]

⭐ The mutation table's second row reproduces exactly: B leaves the three loop pins green and fails only the resume pin. Two independent naive spellings (B, C) are both caught by pin 4 alone, and A shows all four pins discriminate against the unfixed source. Under B the pin dies at its snapshot assertion before reaching the "every item once" assertion, so I also ran my own probes under B: the nested map-in-map probe shows the predicted re-run signature directly — [ 'o1:i1', 'o1:i1', 'o1:i1', 'o1:i1' ] — the resumed map restarting at item 0 on every resume, which is the sentence the new docblock (map-node.ts:236) makes.

Five additional probes of my own, all green at head (file lived only in my worktree, deleted after):

  • P1 empty collection inside a loop — 3 iterations × []: each sees out = [], m.count = 0, no $mapState, run success (attack item 5).
  • P2 nested map → child flow with its own map → pausing grandchild — 2 × 2, four resumes: every leaf exactly once, three suspensions unwind to zero, no $mapState residue in the inner or outer scope (attack item 1, the "nested resume" case).
  • P3 mixed [sync, pause, sync] in one top-level map — snapshot holds started: 2; the single resume runs the trailing sync item inline, terminal path clears the key, after sees the three results.
  • P4 item failure contained by try_catch inside a loop — the entry that is neither suspend nor terminal: no set on the failure path, so iteration 2 re-runs item 0; no residue in any of the three catches.
  • P5 service-automation: a PAUSING map inside a contained region leaves its progress state behind — later loop iterations skip items and the exhausted map returns success having run nothing #15646 reproduction — loop › try_catch › map with a pausing child, 3 × 2: ran = [], two catches, summary.failed = 0, run success — the follow-up's mechanism is real and this PR correctly does not touch it.

The eight attack items

  1. Resume-path argument — every link holds, by reading and by driving. Exactly two writes to stateKey in map-node.ts (set :187 in the suspend arm, delete :238 terminal; the handoff keys at :137–138 are the siblings, not this key). All three suspend-snapshot sites (engine.ts:4387, :5478, :8722) are Object.fromEntries(variables). resumeInternal rebuilds a fresh map — new Map<string, unknown>(Object.entries(run.variables)) at :5298 — and a map: correlation re-enters via executeNode (:5378) with the engine-built $mapItemDone/$mapItemOutput signal folded by applyResumeSignal. The other new Map(Object.entries(run.variables)) (:5633) is a read-only visibility scope for screen-input refusal, not a resume. Bubble (bubbleToParent :5728) goes through the same resumeInternal, so a child completion can only ever read the parent's suspend-time snapshot. No entry path reaches the terminal delete before a read that needs the state: the only reader is the node itself at entry (:128), and the only entries that find the key present are post-resume entries reading the snapshot. ⭐ One link the dev did not claim, worth its own line: applyResumeSignal (:1103) rejects any non-engine-built signal that names an engine variable ($-prefixed or containing .$), so $mapState cannot be forged or clobbered from outside — the suspend-arm write is the only write a resume can read and the only write that can exist.
  2. Second mutation row — reproduced, see table; plus an independent spelling (C) and the re-run signature under B.
  3. All four pins discriminate — mutation A turns all four red for the right reasons (2 child runs; length 2; iteration-1 results re-read and key present; key present after completion).
  4. Census zero — re-derived independently, survives. Predicate: any executor persisting state under a node-scoped key in the shared scope and reading it back on a later entry. Swept every variables.set/delete/get/has in all 15 executor files (13 builtins + plugin-approvals' two), every ${node.id}.-derived key in non-test source, module-scope mutable state, and registerNodeExecutor( repo-wide including apps/, scripts/, and the objectui checkout. Result: map is the only one. The only other ${node.id}. writer is the engine's own output merge (engine.ts:7571), which nothing reads back as progress — the dev's "observed but distinct" note is correct. Legacy $loopItems/$loopIndex are written and never read. try_catch compares $error by identity before/after (innerError !== errorBefore), so a stale $error from a prior iteration cannot count. wait and the approval executors read only $runId/$record/$flowName/$flowLabel; parallel, notify, http, connector, approval-revise touch the scope not at all. Outside packages/, registerNodeExecutor( appears only in two lint scripts. The dev's control ("the same patterns fire on map's seven writes") is consistent with the count I get (4 set + 3 delete post-fix).
  5. Ordering and collateral — state.results is the in-memory array, unaffected by deleting the key; the engine then writes <id>.results/<id>.count from result.output as before. Nothing downstream reads $mapState: repo-wide readers are map-node.ts, resume-authority-gate.test.ts (off a suspended run's variables, :400–401, same side of the line as the dev says), comments in engine.ts, ADR-0019, and CHANGELOGs. Empty collection driven (P1).
  6. runRegion — confirmed at engine.ts:7867–7871: isSuspendSignal(err) → throw new Error('durable pause inside a structured region … is not supported'). Driven by P5. This is both what makes the terminal-only delete complete for the loop case and the mechanism behind service-automation: a PAUSING map inside a contained region leaves its progress state behind — later loop iterations skip items and the exhausted map returns success having run nothing #15646.
  7. Honesty audit. (a) check:type-check-debt --re-measure NOT MEASURED is an honest limit: the gate's own header says --re-measure "refreshes the ledgered packages' dependency closure, then runs tsc per ledger entry", and an unbuilt closure is exit 3 — prerequisite not met, "the run says NOTHING about the ledger"; the dev did not claim a pass. (b) Claim: the claim comment exists (2026-09-05T01:03:24Z, session 03324ae2…, branch named, base named, comments re-read before claiming); assignees are empty and the dev says so. ⚠️ The repo's own CLAUDE.md says "assign yourself and comment"; the dev's "assignee is the PM's field" reading is a deviation from that text, self-reported rather than silently resolved. PM's to rule, not a blocker for this PR. (c) MCP-call arithmetic: the published os-dev-report lists eight calls and claims 8 — internally consistent on GitHub. Per the PM, the dev's off-channel report gives the true total as 9 with the reason; the published figure is then an undercount by one, disclosed rather than hidden. I cannot see the private report and do not re-verify the 9.
  8. service-automation: a PAUSING map inside a contained region leaves its progress state behind — later loop iterations skip items and the exhausted map returns success having run nothing #15646 — exists, filed 01:36:08Z by the dev; describes the suspend-arm residue (write lands, runRegion refuses, no terminal path clears); carries pm:queue only, no domain:* / type / priority, no assignees; body states those are triage's. Mechanism reproduced (P5). One note on its measured block: its printed stateSeen = [ {started: 2}, {started: 2} ] is an aliasing artefact — both entries are the same object mutated in place at iteration 2. Cloned at catch time it reads [ {started: 1}, {started: 2} ], which is what the card's own iteration-by-iteration narrative says. Cosmetic; the narrative is right.

Notes (non-blocking)

  • The docblock/PR/changeset quote new Map(Object.entries(run.variables)) drops the <string, unknown> type parameter that the source carries. Immaterial to the argument.
  • The changeset is patch for @objectstack/service-automation with the pause-path invariant stated — proportionate to a one-line lifetime fix.
  • Observed, not driven, pre-existing, outside this diff: Object.fromEntries(variables) is a shallow snapshot and the in-memory store keeps object identity, so a resumed map mutating state in place also mutates the parked snapshot's object. It would only matter on the consumed-suspension restore path ([Decision] Workflow resume ordering: a thrown node today leaves the run terminally unresumable — which of three shapes, given that the current order buys exactly-once across a crash? #13937 "stranded") with the in-memory store; a serialising durable store is immune. Reported for the PM to decide whether it is worth a card; not a finding against this PR.
  • One-line wording nit: "Both pins were mutated" in the PR body means the two halves (loop pins vs. resume pin); there are four pins. Not misleading in context.

NOT MEASURED (by this review)

  • The full package suite / typecheck / pnpm lint at this head — the PM's re-run is relied on; I ran only the pin file and my probes.
  • The dev's tsc --listFiles coverage claim for the new test file.
  • check:type-check-debt --re-measure (needs the built closure; CI owns it).
  • CI check-run status on the head.

Verdict: PASS. One behavioural line; the resume argument holds on every link and under driving; the naive fix is caught by the resume pin alone in two spellings; the census zero re-derives independently; the follow-up is filed correctly. Landing is the PM's.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 5, 2026 02:01
@os-warren
os-warren enabled auto-merge September 5, 2026 02:01
@os-warren
os-warren added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 7bf96cf Sep 5, 2026
35 checks passed
@os-warren
os-warren deleted the claude/issue-15616-map-loop-iteration-state branch September 5, 2026 02:43
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…d an 'end' node (objectstack-ai#18688)

Part of objectstack-ai#15646

Clause-②: yes

The flow accept set shrinks for five node types inside region bodies —
shapes the runtime never honoured. Ruling D clause 4 states it verbatim.

⚠️ **The runtime half is NOT in this PR.** A region-contained node that
*durably suspends* must fail the run with a named error — only the run
can know that — and ruling D assigns it to a separate `domain:services`
card. ⇒ this PR lands with `Part of`, ⛔ not `Fixes`; **objectstack-ai#15646 stays open
until the runtime half lands.**

---

## ⚠️ SEAT BANNER — ruling **D** superseded the route this body argues
for

Everything below the horizontal rule was written when the card was ruled
**C**, and it argues for **route A** ("Recommendation: A, as
implemented"). ⛔ **That is no longer what this PR does.** It is kept
unedited as the record of how the decision was reached — ⛔ deleting it
would erase the evidence the later ruling was made on.

**What this PR does NOW**, per ruling D (batch objectstack-ai#153 item 1, comment
`5724940095`, maintainer 「其他同意」):

- Inside `loop` / `parallel` branch / `try_catch` (try **and** catch)
bodies at any depth, `FlowSchema.superRefine` refuses **five** node
types: `screen`, `wait`, `approval`, `approval_revise`, and `end`.
- ⛔ **`map` and `subflow` are NOT refused by type.** They pause exactly
when the child flow their `config.flowName` names pauses — a *different
metadata record*, not in hand at parse. Refusing them by type would also
refuse `loop { map(synchronous child) }`, which runs correctly today.
- `packages/spec` keeps its published identifier
`FLOW_PAUSE_CAPABLE_NODE_TYPES`; only its **contents** narrow.
- **`packages/services` is untouched by this round** — measured, zero
paths. The 5-tests-in-3-files cost the section below describes **does
not occur**: the whole package runs **138 files / 1652 tests, all
passing**, with a false-green control proving the test read the rebuilt
artifact and not a stale `dist`.

⚠️ **Corrected by the seat, and it is a DECLARED DEVIATION from ruling
D's letter — ⛔ not a clean pass.** 「zero paths」 is true of **this
round's commits** and ⛔ NOT of the PR's cumulative diff:
`packages/services/service-automation/src/end-node-refused-outcome.test.ts`
(+57/−35) is in the diff, from the earlier round's commit `87973cab8d1`.
Ruling D clause 1 says 「`packages/services` untouched; **the 5 tests**
and objectstack-ai#15616's suite stand」 — and objectstack-ai#15788's region-`end` case **was one of
those 5**. ⇒ the ruling's premise 「B breaks nothing」 was **false for
that one case**: clause 1 itself orders `end` refused at parse, and
`registerFlow` parses, so the old run-time assertion is unreachable by
construction. What was done: the fixture is **byte-identical**, case
count **12 → 12**, and the assertion is **strengthened** (region path,
message text, and that nothing registered) so it fails again the day the
shape becomes declarable. ⛔ No test deleted, skipped or quarantined; ⛔
no engine source moved; objectstack-ai#15616's suite and the other three files are
untouched and green. The at-tier review measured all of this and ruled
it non-blocking — but it is a deviation and it is stated here rather
than buried.

**CI on `6de9d662f6df`: 32 success, 3 skipped, 0 failure, 0 pending.**

### ⛔ Two corrections the `domain:spec` seat owes on its own record

1. ⭐ **The seat ruled "keep the published name" on a premise that is
FALSE.** It told the round that renaming `FLOW_PAUSE_CAPABLE_NODE_TYPES`
removes a *published* export and therefore forces a major. Measured
since: main's `packages/spec/api-surface/automation.json` greps **0**
for that name (lit controls `FLOW_BUILTIN_NODE_TYPES` and
`FLOW_STRUCTURAL_NODE_TYPES` = 1 each; dark control = 0), and the branch
greps 1. ⇒ **the constant is introduced by this PR and is on no
consumer's import path**; the gate's 「1 breaking (removed)」 was computed
against the branch's own earlier snapshot. The *decision* stands and
costs nothing — a second name would be cost without benefit — but ⛔ the
record must not carry 「a removed published export」 as a fact about
consumers. The round measured this and told the seat; the seat
re-measured and confirms it.
2. ⛔ **THIS CORRECTION WAS ITSELF WRONG, and the seat withdraws it.** It
claimed `--pair 18688` re-measured 「exit 0」 at this head. **That reading
came from a STALE INSTRUMENT.** The shared checkout's
`check-clause2-carriers.mjs` is blob `ccd5ad7c9a00` and contains **0**
occurrences of rule **C8**; `origin/main`'s and this head's is blob
`3a270ef2eb5f` and contains **18** (lit control `C1`: 50 vs 51, so the
reader works). C8 landed on `main` at 01:41Z via objectstack-ai#18859 and the shared
checkout never had it. ⇒ every `--pair` reading this seat took today was
taken with a script that cannot see C8. Re-taken with `origin/main`'s
script: **objectstack-ai#18688 exit 4 on C8** — this seat held **two live `Claim:`
comments** on objectstack-ai#15646 (`5722016855`, `5728277407`), which the protocol
forbids. Repaired as C8 prescribes: `Release:` (`5729634742`) then ONE
fresh `Claim:` (`5729639847`). **`--pair 18688` now exits 0** —
`claim.selected` 1, `claim.rejected` 2. The at-tier review caught this;
the seat re-measured and confirms it.

---

Route **C**, as ruled. Director seat, summon objectstack-ai#24, batch objectstack-ai#145 item 5 —
objectstack-ai#15646 (comment)
(maintainer 「同意,其他也同意」), with the batch objectstack-ai#146 scope addition —
objectstack-ai#15646 (comment)
(maintainer 「146 同意」), which attached objectstack-ai#3267's 禁 ruling and absorbed
objectstack-ai#18112 into this card. One PR, one changeset, two refusals in one rule
family.

## 🛑 Read this first — this PR is NOT ready to land, and the reason is a
measured decision, not a bug

`packages/spec` is green end to end. **5 tests in 3
`packages/services/service-automation` files now fail**, and every one
of them fails for the same reason: the fixture can no longer be
REGISTERED, because `AutomationEngine.registerFlow` parses through
`FlowSchema.parse` (`engine.ts:3941`) and this rule refuses the shape.

⚠️ **Corrected by the `domain:spec` seat after a classification round —
the table below replaces one that named 5 tests in 3 files.** That
earlier count was taken by running **three named files**; CI runs `pnpm
--filter @objectstack/service-automation test`, the whole package, and a
named-file subset cannot see this class of breakage. `os-dev.md:56`
reserves this body to the PR-open write, so the round named the wording
and the seat writes it.

| File | Failing | Card | What it pins |
| --- | --- | --- | --- |
| `src/builtin/contained-failure-rollup.test.ts` | **7** | objectstack-ai#16314 | the
contained-failure rollup fold over `loop { subflow }` — ⚠️ **absent from
the earlier table entirely**; it predates this branch's base (`git
merge-base --is-ancestor` exit 0), so this is a measurement gap, ⛔ not
drift |
| `src/builtin/map-in-loop-iteration-state.test.ts` | 3 | objectstack-ai#15616 | `loop
{ body: [ map, probe ] }` over a **non-pausing** child: 5 iterations x 2
items ⇒ 10 child runs, `failed = 0` either way, a fresh result set per
iteration |
| `src/builtin/contained-failure-visibility.test.ts` | 1 | objectstack-ai#14456 | a
parent run's row identity does not leak into a `subflow` child; the
region shape is the **vehicle**, not the subject |
| `src/end-node-refused-outcome.test.ts` | 0 (was 1) | objectstack-ai#15788 | ⭐
**fixed on this branch** — see below |

**Measured with the package suite:** at `e10b395cee`, **12 failed / 1627
passed (1639)** across **4 files**. After the fix below, at
`87973cab8d1`: **11 failed / 1628 passed**.

⭐ **One of the twelve was never blocked on the open question, and it is
repaired here.** objectstack-ai#15788's region-`end` case sits in **both** candidate
populations — this body defines route B as the unconditionally pausing
types **plus `end`** — so no answer to the question below moves it. It
is re-homed to the registration refusal: the fixture is unchanged byte
for byte, and the case now asserts the ZodError's located path, its
message and prescription, and that **nothing registered**. ⛔ Not a
deletion — it fails again the day the shape becomes declarable.

**The 11 are mutually exclusive with route A, and that is measured
rather than argued.** Ablating `FLOW_PAUSE_CAPABLE_NODE_TYPES` to route
B's definition turns **all 11 green with nothing else moving**; route A
on the same four files is 11 red. ⚠️ Method note that is load-bearing:
`service-automation` resolves `@objectstack/spec` through **`dist`**, so
the ablation was rebuilt and verified present in 18 built artifacts
before anything was read — an unrebuilt ablation would have gone green
and proved nothing. Restored afterwards, verified absent from all 216
artifacts, whole-tree porcelain empty.

⭐ **The 11 are NOT one cost.** 3 of them (objectstack-ai#15616) are free: under route
A the shape becomes undeclarable, so the defect is unreachable and the
regression suite converts to a refusal pin — mechanically, the same
conversion performed above for objectstack-ai#15788; that file's second describe (a
TOP-LEVEL pausing map) is untouched and green, so the durable-pause half
keeps its coverage. The other 8 (objectstack-ai#16314, objectstack-ai#14456) are a genuine re-home
onto a top-level delegating node, and `loop { subflow }` over five rows
with one failing is the shape objectstack-ai#15617's ruling **named**, so any re-home
must record that the measurement no longer runs on it.

⛔ **Not repaired here.** The dispatch fences `packages/services` ("the
engine's runtime refusal stays exactly as it is") — ⚠️ and note
precisely what that fence claims: it is true of the **diff**, which
touches no `packages/services` file. Read as a claim about **effect** it
is false, because the parse refusal changes what those suites can
register. The changeset carries the same correction, and two of these
three are other cards' regression suites: deleting or re-homing objectstack-ai#15616's
and objectstack-ai#15788's coverage is a decision, not a fixture edit. **Two of them
are also evidence about the rule itself**, which is the open question
below.

### The open question: does the narrowing take a shape that WORKS with
it?

The ruling's population is "a node that **can durably pause** (`map` /
`subflow` **with a pausing child**, approval-class nodes)". Measured:
`map` and `subflow` pause **exactly when the child flow they NAME
pauses** — a different metadata record — so "with a pausing child" is
**not decidable at parse**. Only two spellings are:

- **A — judge the node TYPE** (what this PR implements). Closes this
card's own reproduction, covers all three region kinds, and is the only
reading under which C is the *complete* fix the ruling's own reasoning
requires. **Cost, measured:** it also refuses `loop { map(synchronous
child) }` — a shape that runs correctly today and was deliberately fixed
12 days ago by objectstack-ai#15616 / PR objectstack-ai#15648, whose regression suite is 3 of the 5
failures above.
- **B — judge only the UNCONDITIONALLY pausing types** (`screen` /
`wait` / `approval` / `approval_revise`) plus `end`. Refuses nothing
that works today, and the 3 `map` failures disappear. **Cost:** this
card's own reproduction — `loop { try_catch { map(pausing child) } }` —
stays declarable and stays silently green, so the card is not closed.

There is no third reading available to a parse. **Recommendation: A, as
implemented** — objectstack-ai#3267 is ruled 禁 ("structured regions do not support
durable pause"), and a shape whose legality lives in a record the author
is not editing, revocable by editing that record, is not a contract.
Under A the five tests are re-homed (a top-level `map`, a top-level
`end`) or retired with a statement, in this PR or a follow-up, once the
seat says the coverage may move.

## Step Zero — the ruling's precondition, answered before any code was
written

> **First step, before writing**: prove the nesting is statically
decidable at parse/validate time.

**Answer: YES for the nesting and for the node vocabulary this rule
judges, with two boundaries that are declared rather than discovered.**
What was measured, on this branch's base `7f7b8557df`:

1. **The nesting is decidable, and a refusing layer already exists.**
`collectFlowGraphs` (`packages/spec/src/automation/control-flow.zod.ts`)
yields the top-level graph plus every region body, depth first, with a
`scope` label and a `path` that anchors a Zod issue where the author
wrote the node. `FlowSchema`'s `superRefine` already walks exactly that
and refuses on it — the objectstack-ai#16134 one-node-id-space rule. The PM seat's
clue held: there is no *refusing* layer for this shape, but the walk and
the refusal machinery are both live and in the same file.
2. **The pausing vocabulary is statically declared for the built-in
set.** Derived by reading the shipped `defineActionDescriptor` literals,
not by recall: `supportsPause: true` appears on `screen` / `wait` /
`subflow` / `map` (`packages/services/service-automation/src/builtin/`)
and `approval` / `approval_revise`
(`packages/plugins/plugin-approvals/src/`) — six, the same six the
ADR-0044 `resumeAuthority` default-flip migration entry names in its own
prose. They are published here as `FLOW_PAUSE_CAPABLE_NODE_TYPES`.
3. **`end` is fully static** — `FLOW_STRUCTURAL_NODE_TYPES`, a node type
the engine handles with no executor at all.

**What is NOT decidable, and what this rule does about it.** Whether a
given node *will* pause is not decidable at parse, in two different
ways, and both are stated in the docblock, in the changeset and in the
ADR-0087 entry:

- **`map` / `subflow` pause exactly when the child flow they NAME
pauses** (`map.config.flowName`, an opaque reference to another metadata
record). So the rule judges the node **TYPE**, not the run. That is
wider than the runs that actually broke — a region-nested `map` over a
synchronous child parsed green before and is refused now — and it is
deliberate: the old shape's legality lived in a record the author is not
editing and could be revoked by editing that record. "Legal until
somebody adds a `wait` to the child flow" is not a contract.
- **A plugin-registered pausing type is invisible to a parse.** ADR-0018
left the node-type namespace open (`FlowNodeSchema.type` is a validated
`string`), and a parse has no registry. Pinned as a boundary test so it
moves deliberately.
- **`MAX_REGION_DEPTH` (32).** The walk stops there. ⚠️ Unlike objectstack-ai#16134's
duplicate-id rule, there is **no second spec refusal behind the
ceiling** for this rule — `analyzeRegion` says nothing about pausing
nodes — so past depth 32 the engine's run-time refusal is the only one.
Measured and pinned at nesting 32 (refused) / 33 (not judged), and
stated in the changeset rather than left for an author to find.

## What changed

`FlowSchema.superRefine` gains one walk over `collectFlowGraphs`,
skipping the flow's own graph, that raises a `custom` issue anchored at
`[...regionPath, 'nodes', i, 'type']` for:

- **a pause-capable node** in a region body — the message names the
node, the region scope (`loop 'sweep' body → try_catch 'guard' try`),
why a region body cannot host it, and the fix;
- **an `end` node** in a region body, whatever its `outcome` — an `end`
there was a no-op, and a refusing one was converted into a region error
at the same boundary (objectstack-ai#15788). The ruled prescription is the message: a
region body cannot end the run; put the `end` on the top-level graph.

`FLOW_PAUSE_CAPABLE_NODE_TYPES` is the new export (`api-surface` /
`export-origins` regenerated). The two approval entries are the declared
constants `APPROVAL_NODE_TYPE` / `APPROVAL_REVISE_NODE_TYPE`, so a
rename cannot desynchronise them.

⛔ `packages/services` is untouched — this is authoring-time enforcement
only. ⛔ No engine rollback seam (route A, no card filed, per the
ruling). ⛔ No runtime detection in `map` (route B, refused). ⛔ objectstack-ai#15617's
`failed` fold is not addressed.

## Tests

New file
`packages/spec/src/automation/flow-region-pause-and-end.test.ts` — every
case fails without the rule:

- both refusals × all three region kinds: `loop` body, `try_catch` try
**and** catch, `parallel` branch. A rule covering `loop` only is route B
wearing C's clothes; the `try_catch` catch arm and the `parallel` branch
arm are the two route B could never see, and each has its own case.
- all six pause-capable types, table-driven off the exported constant.
- the card's own reproduction, `loop { try_catch { map } }`, refused
with the chained region path.
- **negative tests, the over-reach guard**: every pause-capable type and
an `end` still parse on the **top-level graph**; every non-pausing type
still parses inside a region; a node merely *named* `end` or `wait` in a
region still parses (the rule judges `type`, not `id`).
- both declared boundaries pinned: the plugin-contributed pausing type,
and the depth-32/33 seam.
- `defineFlow` and `formatZodError` renderings.

Two existing cases pinned the behaviour this rule replaces and were
**replaced rather than re-spelled**, each saying so in its own comment:
`end-node-outcome.test.ts`'s region-nested `end` (its subject — an
`end`-in-region whose *config* is judged one door later — no longer
exists) and `flow.test.ts`'s BPMN `waitEventConfig` region case (now
asserts the earlier refusal *and* keeps the region-contract half it
actually exists to measure). The `requireTypeScopedConfig` docblock that
asserted a nested block-less `wait` parses green was corrected in the
same edit.

## Verification

Measured on `e10b395cee`. Heavy runs go through
`scripts/pm/os-verify-lock.sh`; every exit code below is read from the
wrapper's own `VERDICT command-exit` line or captured into a variable
**before** any pipe — never `$?` after one.

| Command | Verdict |
| --- | --- |
| `pnpm --filter @objectstack/spec build` | `command-exit 0` |
| `pnpm --filter @objectstack/spec test` (whole package) | `command-exit
0` — **486 files, 13895 tests, 0 failed** |
| `pnpm --filter @objectstack/spec typecheck` (`tsc --noEmit` +
`check:scripts-typecheck` + `check:test-typecheck`) | `command-exit 0` |
| `pnpm --filter @objectstack/spec check:generated` | `command-exit 0` —
all 15 artifacts up to date |
| `pnpm lint` (whole repo, `eslint . --no-inline-config`) | `exit 0` —
run in full, so nothing here is a narrowing |
| `dispatch-gates.mjs --ran` reconciliation | `exit 0` — **85 derived,
81 run, 4 NOT-MEASURED, 0 UNRUN** |
| `@objectstack/service-automation` — the 3 files whose fixtures feed
this rule | `exit 1` — **5 failed / 24 passed**, see the section at the
top |

**Reverse verification (one-shot, restored).** The rule's own early-exit
was mutated (`graph.path.length === 0` → `>= 0`), and the mutation was
proved on disk before anything was read from the run — anchor grep 1 →
0, marker grep 0 → 1, blob `044bbbba` → `56a1c350`:

- **ablated** — `flow-region-pause-and-end.test.ts`: **20 failed / 7
passed**. The 20 are exactly the refusal assertions; the 7 that survive
are the over-reach guards and the two boundary pins, which must stay
green with the rule absent. That split is itself the reading: a rule
that also broke the negative cases would be refusing too much.
- **restored** — `git checkout HEAD --` the file, blob back to
`044bbbba`, `git diff HEAD` clean, `git status` empty, same file **27/27
passed**.

No `dist` preflight applies: the test imports `./flow.zod` by relative
source path, so the subject never resolves through `packages/spec/dist`.
A restore `trap` was armed for the whole window.

**The four NOT-MEASURED gates** are `check:doc-formula-expressions`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure` and
`check:type-check-debt` — each exited **3, PREREQUISITE NOT MET**,
printing in its own words that nothing was measured. All four read built
output across packages this diff does not touch and need a repo-wide
build; CI's `Build Core` and `Lint & Repo Gates` are where they run. ⛔
Not green, not red — unrun.

**Not measured, stated:** CI convergence on this PR (the report is filed
at the end of local verification); the branch has not been merged
forward since `7f7b8557df`, so `main`'s newer commits are tested by CI
and the queue rather than here.

**Review-gate reading, not an action.**
`scripts/pm/check-clause2-carriers.mjs --pair 18688` exits **4** on two
rows, both belonging to the claiming seat and ⛔ neither touched here:
**C1** — card objectstack-ai#15646 carries `needs:contract-review` while this PR does
not (the gate is a dual carrier); **C2** — no comment on the card's
thread is a machine-legible claim comment (none has a first line
beginning `Claim:` carrying the `Clause-②:` line), so the declaration
limb has nothing to read. The declaration itself is at the top of this
body and in the changeset.

## Acceptance notes

Observations from this card's reading, recorded here and **not** filed —
none is a reproducible defect, a contract violation, or an authoring
trap:

- The ruling's own parenthetical, "`map` / `subflow` **with a pausing
child**", describes the defect population rather than a decidable rule
population, and its "a shape the runtime never honoured" is exact for
`end`, `screen`, `wait` and the approval pair but not for a `map` over a
synchronous child, which runs today. The PR takes the capability reading
— the only one that makes C the complete fix the ruling's own reasoning
requires — and the changeset states the cost in the author's own terms.
Noted so a reviewer reads the widening deliberately rather than
discovering it.
- The card body and the batch objectstack-ai#145 ruling both say objectstack-ai#15617 is open; it is
**closed / completed**. Nothing here depends on it.
- `engine.ts:9937` in the ruling reads `engine.ts:9970` on this tree —
line numbers are clues, and this one was re-read rather than trusted.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-automation: a map node inside a loop body runs its collection ONCE — iterations 2..n do nothing, report success, and the run completes green

2 participants