Repository navigation
fix(service-automation): scope a map node's progress state to one execution of its collection - #15648
Conversation
…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
📓 Docs Drift Check1 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
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
Clause-② contract review — PR #15648 (card #15616) · PASSTier line. Reviewed at head 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
⭐ 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 — Five additional probes of my own, all green at head (file lived only in my worktree, deleted after):
The eight attack items
Notes (non-blocking)
NOT MEASURED (by this review)
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 |
…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>
Fixes #15616
A
mapnode inside aloopbody ran its collection on the first iteration only. Iterations 2..n ran nothing, the map step reportedsuccesson every one of them, and the run finishedcompleted.Mechanism
maptracks its progress through the collection in the flow variablenodeId.$mapState, and wrote it into the flow's shared variable scope without ever removing it. Aloopbody region runs in that same scope by construction —runRegionis handed the caller'sMapdeliberately, 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 backstarted === collection.length, correctly concluded there was nothing left to start, and returned.The lifetime was the defect, not the key.
$mapStatewas 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:
mapwrites$mapStatein two places — the suspend arm (map-node.ts, before returningsuspend: true) and the terminal arm. Only the terminal one changes.Object.fromEntries(variables)) into theSuspendedRun. On resume,resumeInternalrebuilds the scope withnew 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.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
mapis in any case unreachable from inside aloopbody:runRegionconverts 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 realAutomationEngine, no mock of the mechanism:completedwithsummary.failed === 0and the work happened — pinning whyfailedcould never have been the instrument that caught this;nodeId.$mapStateis absent from the scope after the map completes (the mechanism asserted directly, not inferred from the child-run count);$mapStateis present in the suspend snapshot withstarted: 1, and driving the pauses through runs every item exactly once.Both pins were mutated to confirm they discriminate, restoring from
HEADeach time and verifying the restore bygit diff HEADplus a blob-hash comparison:deletereverted toset(the pre-fix spelling)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.deletein every builtin, every key expression derived fromnode.id, module-scope mutable state, writes into the shared context, and everyregisterNodeExecutorin the repo — including the two inplugin-approvals, which are outside this package.mapis the only one. Every other scope write is an author-named output variable (crud,screen,subflow,logic,try_catch'serrorVariable) or a loop's iterator/index binding — written each time, never read back as progress.waitand the approval nodes only read the scope;parallelnever touches it. The zero has a firing control: the same patterns hitmap's seven writes, including the defective one, and hitwait's and the approval nodes' reads.⇒ No second instance of the identical mechanism, so nothing beyond
mapis changed here.Found and NOT fixed
mapinside a contained region leaves its progress state behind — later loop iterations skip items and the exhausted map returnssuccesshaving run nothing #15646 — the same key reached through the other arm. When a pausingmapsits inside a contained region, the suspend write lands but the pause is refused byrunRegion, leaving residue no terminal path can clear. Measured: 3 iterations x 2 items, 0 items ever completed, only 2 of 3 iterations reached the catch, and iteration 3 returnedsuccesshaving run nothing. Closing it needs a ruling on which layer owns cleanup when a suspend is refused, so it is filed rather than guessed.FlowRunSummary's two paragraphs disagree for a subflow parent —failedis declared a node fold, while the summary is declared to answer "what did this run cause" and roll a child's totals up #15617 is not addressed here; that run'ssummary.failed = 0over two contained failures is recorded on service-automation: a PAUSINGmapinside a contained region leaves its progress state behind — later loop iterations skip items and the exhausted map returnssuccesshaving run nothing #15646 as a data point for it.Gates
pnpm lint(full repo) exit 0; packagetest108 files / 1294 tests andtypecheckboth exit 0, re-run at the final commitac3079c43. ADR-0087 via the real invocationnode scripts/check-adr-0087-registration.mjs --base origin/main --head ac3079c43exit 0 (1 non-breaking changeset(s) seen), plus its--self-test. The gate set was re-derived from the actual changed files withscripts/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