You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[finding] service-automation: a child that PAUSES and then refuses on its resumed leg still rolls up fail-open — #18110/#18555 fix the synchronous leg only, and the up-bubble path strands the parent paused forever #18714
Surfaced by the contract-review-tier review of PR #18706 (the #18110 + #18555 delivery) and filed by the domain:services seat, session_01QGMBhvUoyD8t5zY8xHQhnP. ⛔ Unlabelled for domain:* and ungraded on purpose — routing and grading are the triage seat's.
⚠️Neither leg below is a regression from #18110/#18555. Both pre-date that change, which deliberately covers only the synchronous engine.execute return in the two executors, per its governing ruling. This card exists so the remaining half is tracked rather than implied-fixed by the release note.
Measured, ⛔ not read off the code
Driven with a scratch probe (subflow → a pausing node → a refusing end) by the reviewer at PR head af0edd84f:
leg
measured result
Delegated resume — engine.resume(parentRunId)
parent returns { success: true, successMessage: … }, parent run row records completed, and the downstream node ran. ⇒ the identical fail-open shape #18110 fixes, still open here.
Up-bubble — engine.resume(childRunId)
child row records refused, but the parent stays paused and remains in listSuspendedRuns()indefinitely.
Call sites behind it:
packages/services/service-automation/src/engine.ts:6266–6350 — the delegated-resume branch tests only paused / !success, so a refused child falls through the ordinary success exit (exactly the branch-set shape the two fixed executors had).
engine.ts:6676 — bubbleToParent is called only on the completion path; the refusal arm at :6696 returns without bubbling, which is why the parent is never resolved either way.
⭐ Why this is worth a card rather than a note
engine.ts:6689–6693 describes the resumed leg, in its own words, as 「the one a screen flow actually takes」 — so the uncovered leg is the common one for the feature that made refused reachable at all (#15788). The two halves also fail differently: the delegated leg loses the refusal silently (fail-open), while the up-bubble leg strands a run in paused (a leak, visible in listSuspendedRuns()).
⛔ It does not prescribe the fix. Whether the resume path reuses the same FlowRefusalSignal channel that PR added, or whether the up-bubble arm should bubble a refusal, is a design question that touches the engine's resume machinery.
resumed leg refused rollup · bubbleToParent refusal arm no bubble · engine.resume parentRunId refused child · parent stays paused listSuspendedRuns · 18110 synchronous leg only
Filed at 2026-09-17T16:23Z. ⛔ No domain:*, no priority, no pm-state — deliberately bare for the triage sweep's disjunction ①. Evidence provenance: the at-tier (claude-fable-5-1, confirmed from the reviewer's transcript) clause-② review of PR #18706, advisory A2.
Deferred this round — serial behind #18881, and the fold-or-serial question answered explicitly
domain:services seat (#6021) · session_019hBqDVrwbijUCoK9qsss2E · R1 · written 2026-09-18T21:47Z. ⛔ Report-only: no label written, no claim taken, this card keeps pm:queue and stays dispatchable. Board readings taken at this fire.
Why this is written now rather than when the card is picked up
This card and #18881 are both p1, both pm:queue, and both land in packages/services/service-automation/src/engine.ts. That is a shared hot file between two queued cards, so the protocol requires this seat to answer fold or serial out loud rather than let a default happen. #18881 was dispatched this fire; this card was not. The reasoning is recorded here so the next seat inherits a decision, ⛔ not a silence.
Fold-or-serial — SERIAL, and two of the five gates fail
gate
verdict
① same defect shape, same repair
⛔ FAILS.#18881 is a region-containment defect: a node inside loop / parallel / try_catch durably suspends and the run reports success with nothing run. This card is a resume-leg rollup defect: a child that pauses and then refuses is either lost fail-open (delegated resume) or strands its parent in paused (up-bubble). Same file, same subsystem — ⛔ not the same defect and not the same repair. Same keyword is ⛔ not gate ①.
② same package / region
✅ passes — both service-automation, both engine.ts.
③ every member already ruled or graded
⛔ FAILS for this card.#18881 carries a maintainer ruling (batch #153 item 1, ruling D on #15646). This card is graded p1/Bug by triage but the card itself says verbatim 「⛔ It does not prescribe the fix」 and names the mechanism choice as 「a design question that touches the engine's resume machinery」.
④ each member independently checkable
✅ passes — the card names two legs, each with its own measured signature.
⑤ exclusion list named
✅ this comment is it.
⇒ Two independent gate failures. ⛔ Not foldable. They run serial, #18881 first.
🔴 The re-pricing question the next dispatch must answer BEFORE this card goes out
⛔ Do not dispatch this card on the estimate that stands today. When #18881's PR lands, its dev's report must be read against this card with the mandatory question:
Does #18881's change make this card simpler, harder, unnecessary, or leave it unaffected?
This is not a formality here — #18881 adds a named structured failure when a contained node durably suspends. If that failure fires on the shape this card's probe drives, part of this card may already be closed by it. ⭐ The seat's own guess is that it does NOT — this card's probe is subflow → pausing node → refusing end, which need not sit inside a structured region at all — but that is a guess, ⛔ not a reading, and it is recorded here as a falsifiable assumption rather than a premise.
Readings this seat took at 2026-09-18T21:40Z that this card should not have to re-take
Claim: PM loop round 1
Session: session_019hBqDVrwbijUCoK9qsss2E
Branch: claude/issue-18714-resumed-leg-refusal-rollup
Worktree: objectstack-issue-18714
Domain: domain:services
Seat: domain:services#1
File surface: packages/services/service-automation/src/ + .changeset/ (stop on breach; explain in the report)
Container & model: M, mode:subagent, model: default judgment tier (opus) — this act's dispatch-gates.mjs --repo objectstack-ai/objectstack --tier packages/services/service-automation/src/engine.ts returned verbatim 「no path-derived mandate: the surface hits none of the 3 declared glob(s) … floor sonnet · default opus · ceiling fable」. Kept at default: the card names two legs and ⛔ does not prescribe the fix, so the mechanism is a live design judgment.
Clause-②: no
Thread-read: 5736595238
Serial constraints cleared: the hot file is engine.ts. Its previous holder #18881 MERGED as PR #19140 (verified on origin/main with controls), which is what unblocks this card. Lane pm:dispatched now = #18336 (this seat's, surface core/security + plugin-auth + plugin-security + organizations test ⇒ disjoint from service-automation) and #16506 (⛔ another seat's, on src/email-service.queue-delivery.test.ts ⇒ disjoint). ⛔ No sibling card pins behaviour this one asserts.
🔴 Re-pricing against the card that was blocking it — the mandatory question, answered from measurement
Does #18881's change make this card simpler, harder, unnecessary, or leave it unaffected?
⭐ Unaffected. ⛔ Not a guess this time — when this card was deferred, this seat wrote that its probe 「need not sit inside a structured region at all」 and explicitly marked that a guess, not a reading. It is now measured on origin/main after #19140 landed:
probe
reading
isRegionSuspensionRefusal call sites in engine.ts
3 — the import (:214), the one-refusal-one-failure suppression (:9528), and a re-throw (:10111)
…of those, any on the resume or up-bubble path
0
bubbleToParent sites
9 — still called on the completion path (:6689), defined at :7067; the refusal arm's structure is unchanged
negative control (bogus token)
0
⇒ #18881 fires inside runRegion / executeNode. Neither of this card's two legs is touched, so its cost, its shape and its two-pin acceptance all stand as filed. ⛔ The estimate is ⛔ not carried over from filing time — it is re-taken here.
⚠️ One NEW adjacency #19140 created — a question for the dev, ⛔ not a finding and ⛔ not a fence
engine.ts:10111 now re-throws on isRegionSuspensionRefusal. A run that resumes into a structured region can now meet that error type on a path this card is rewriting. ⇒ check whether the resume legs need to handle it, and say what you found. This seat did ⛔ not establish that they do.
Standing constraints carried from the card and its triage
os-dev-report
{
"issue": 18714,
"status": "done",
"branch": "claude/issue-18714-resumed-leg-refusal-rollup",
"pr": "#19158",
"session": "session_019hBqDVrwbijUCoK9qsss2E",
"premise_still_valid": true,
"summary": "Both resumed legs of the subflow chain now roll a child run's REFUSAL up to its parent. The delegated-resume block (engine.resume(parentRunId)) records the refusal when the child it drives answers status 'refused'; bubbleToParent gains a refusal argument so a child resumed directly (engine.resume(childRunId)) also resolves its parent instead of leaving it parked. Both arms hand the refusal to ONE throw site inside the resume's traversal try-block, past claimAdvance/forgetSuspendedRun and before the traversal, which raises the existing internal FlowRefusalSignal so the run terminates through the single finishRefusedRun chokepoint: parent row reads 'refused', carries the child's rendered refusalMessage, fires no successMessage, walks no out-edge, and keeps the child's #4354 rollup. Multi-level chains resolve by the same induction completions already use. ISSUE BODY: no sanitizer truncation — the body ends on its intact dedupe-words section and footer, and both comments plus the PM claim read whole. CLAIM VERIFIED: newest Claim: comment 5737929003 names this branch. ASSIGNEE: set by the PM to huangyiirene before dispatch; this run never wrote it.",
"tests": "ABLATION (two legs, one at a time, via scripts/ablation-replace.mjs — anchor-must-hit, on-disk blob proof, proven restore). The pins resolve ./engine.js from package SOURCE (in-package relative import, no dist on the resolution path), which the reddening itself demonstrates. LEG 1, delete the delegated-leg detection: tool printed 'anchor 1 to 0, blob 90ccd6c29edf to fe1d7763fc67'; result 4 failed | 7 passed; the four reds are all leg-1: "expected undefined to be 'refused'" (twice), "expected undefined to be 'Refused: vip is not eligible'", and "expected [ 'child-work', 'downstream' ] to deeply equal [ 'child-work' ]"; every leg-2 assertion and both controls GREEN. LEG 2, delete the up-bubble of the refusal: 'anchor 1 to 0, blob 90ccd6c29edf to 6098b376281a'; result 3 failed | 8 passed; the three reds are all leg-2: "expected 'paused' to be 'refused'" (twice, on the parent's run row and on its summary) and "expected true to be false" (hasSuspendedRun on the parent); every leg-1 assertion and both controls GREEN. Two DISTINCT failure signatures — wrong-answer fail-open versus leaked-run — not one measurement restated. Both restore legs printed 'ok restored: blob == HEAD (90ccd6c29edf) and git diff HEAD is empty', and git status --porcelain was empty after each. TWO leg-2 assertions are measured green on BOTH sides and are annotated in the file as such, so no reader mistakes them for pins: the downstream-did-not-run one, kept as the pin on the WRONG fix of bubbling the refusal as a completion; and the child-envelope invariance one. SUITES: pnpm --filter @objectstack/service-automation test printed 'Test Files 140 passed (140)' and 'Tests 1671 passed (1671)', exit 0 — so the synchronous leg (#18110 and #18555), the region refusal (#18881) and the retryable delegated resume-bag codes (#14379) keep their pins. pnpm --filter @objectstack/service-automation typecheck exit 0 including check:test-typecheck ('0 file(s) / 0 error(s)'). New pin file alone: 'Test Files 1 passed (1)' and 'Tests 11 passed (11)'. All heavy runs went through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-18714; every verdict read off its printed 'VERDICT command-exit' line, never a bare exit variable after a pipe.",
"gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (NO paths passed; change set derived from merge base 6819dcb) yielded 59 commands. ALL 59 RUN, exit codes landed to disk, reconciled with --ran: 'dispatch-gates --ran: 59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED (a DERIVED zero — all 59 recorded an exit code and none of them is 3)', exit 0. Two gates first answered exit 3 PREREQUISITE NOT MET (pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt — both demand a built closure); after 'pnpm exec turbo run build --filter=./packages/* --filter=./packages// --concurrency=2' (72/72 tasks successful, exit 0) both were re-run and answered exit 0 ('104 published require entry point(s) across 67 package(s) load' and '4 ledger entr(ies) re-measured, 53 raw tsc error(s) total, none above its recorded number'). REPO-LEVEL SCAN, run whole and NOT narrowed: pnpm lint (eslint . --no-inline-config) exit 0 on the final commit b2203ba. pnpm check:nul-bytes green, plus a hand grep -naP over the three changed files for control bytes: zero hits. CLAUSE-2: node scripts/pm/check-clause2-carriers.mjs --pair 19158 exit 0, verdict 'the clause-② declaration is readable in the fixed spelling and both carriers agree, and its diff carries no widening tell' — card claim and PR body both DECLARED 'no'. NO needs:contract-review is owed on this PR and none is present on it. REMOTE CI on PR head b2203ba: 34 check runs completed, 31 success / 3 skipped / 0 failure — all SEVEN required contexts GREEN (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard). Not a substitute for CI's own account: the 51 artifact-roster families, 11 declared-wide families, 5 path-scheduled CI jobs and the always-runs tail sit outside the derived 59, as the tool states.",
"line_budget": "NOT APPLICABLE — the diff touches no skills/** package and no published SKILL.md, so no line ratchet is owed and none was paid. Diff size for the record: 3 files, +525/-4 versus origin/main — engine.ts +184/-4 (two detection arms, one shared throw site, one module-private interface, docblocks), resumed-leg-refusal-rollup.test.ts +318 (new), .changeset/18714-resumed-leg-refusal-rollup.md +27 (new). The auto-labeler put size/l on the PR; that is another actor's write and was left alone.",
"deviations": "NONE that narrow verification: the repo-level lint ran whole rather than narrowed, and all 59 derived gate families ran with zero NOT-MEASURED. Two deliberate, declared choices worth the seat's eye. (1) The two resumed legs share ONE throw site and one terminal chokepoint, so a mutation AT THE SHARED SITE would redden both pins. The legs are kept independently dissolvable by their own detection arms, which is exactly what the two ablations above mutate; the two-pin acceptance should be read against those, not against the shared site. (2) The changeset is a patch, not a minor: nothing published moves (ChildRunRefusal is module-private, resumeInternal and bubbleToParent are private), which is also why Clause-2 is 'no'. No label was written from here, per the dispatch's write budget. No PR body PATCH was issued — the body was written once, at creation, and read back byte-identical. FILE SURFACE HELD: packages/services/service-automation/src/ plus .changeset/ and nothing else; zero packages/spec.",
"files_changed": [
"packages/services/service-automation/src/engine.ts",
"packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts",
".changeset/18714-resumed-leg-refusal-rollup.md"
],
"zone2_findings": "ASSUMPTION 1 (the blocker #18881 / PR #19140 does not change this card) — CONFIRMED by re-measurement, not carried over. On origin/main isRegionSuspensionRefusal has exactly 3 sites in engine.ts (import :214, the one-refusal-one-failure suppression :9528, the inner-boundary rethrow :10111); on this branch the same 3, shifted to :214 / :9704 / :10287 by my insertions. NONE is on the resume or up-bubble path. bubbleToParent was called on the completion path only (:6689 on main); my change adds exactly one more call site and changes nothing about the existing one. Negative control: a bogus token grep returned 0 hits, exit 1. ASSUMPTION 2 (the NEW :10111 rethrow adjacency — a question, not a fence) — ANSWERED: the resume legs do NOT need to handle it. The two predicates are structurally disjoint. FlowRegionSuspensionRefusalError is an Error subclass branded with a registered Symbol.for and carries no __flowRefused; FlowRefusalSignal is a non-Error sentinel carrying __flowRefused and no symbol. Neither isRefusalSignal nor isRegionSuspensionRefusal can answer true for the other's value. A run that resumes INTO a structured region and meets the region refusal still falls to resumeInternal's generic failure arm and is failed, which is #18881's intended outcome; my arms sit above that and never intercept it. Held by the full package suite staying green, region-durable-suspension-refusal and nested-region-parity included. ASSUMPTION 3 (the card's line numbers have drifted) — CONFIRMED, and re-located by behaviour rather than by number: the delegated-resume branch the card cited at :6266-6350 is the correlation.startsWith('subflow:') block, and the bubble arm it cited at :6676 / :6696 is the completion-path bubbleToParent call and the isRefusalSignal catch arm beside it. ASSUMPTION 4 (whether to reuse PR #18706's FlowRefusalSignal channel) — MEASURED and REUSED, and it is the right channel: both existing producers of a refusal already unwind through it to finishRefusedRun, the resume path already carries an isRefusalSignal catch arm, and reusing it means the resumed legs mint no second unwinding protocol, no second terminal exit and no new vocabulary.",
"issue_18112_reading": "READ IN FULL — the card plus its single ruling comment (hotlong, 2026-09-17, director batch #146 item 2, maintainer 「146 同意」). What it CLOSED: letter A — a refusing end inside a structured region stays a loud failure at the region boundary, plus the authoring-time refusal folded into #15646. What it deliberately left CLOSED: option B — teaching try_catch (and parallel / map) to RETHROW the refusal instead of catching it, and the paired decision about the in-flight siblings of a parallel gateway; B 'reopens only with #3267's 「支持」, for both signals together'. MY MECHANISM STAYS OUTSIDE IT on all three counts: (a) it teaches no container to rethrow — runRegion, try_catch, parallel, loop and the map executor are untouched by this diff; (b) it decides nothing about parallel siblings; (c) it adds no engine surface. It also cannot reach that territory by construction: a region body runs synchronously inside the enclosing run and cannot carry a durable pause at all (#3267 禁, enforced at authoring by #18688 and at runtime by #18881), so the shape this card fixes — a run PARKED at a subflow or map node awaiting a child — can never be inside a region body. The two arms are on the top-level durable-resume machinery only.",
"mcp_calls": "0 — no MCP GitHub tool was called, read or write. Every GitHub read and write went through the REST proxy with curl and GITHUB_TOKEN.",
"api_writes": "2 — POST /repos/objectstack-ai/objectstack/pulls (draft PR #19158) and POST /repos//issues/18714/comments (this report). No label write, no PATCH, no POST /issues. The three git pushes to the feature branch are git, not REST writes.",
"open_questions": [],
"out_of_scope_findings": [
"noted, not filed: the delegated-resume block special-cases the 'subflow:' correlation only, so a map parent reaches its child's outcome exclusively through the up-bubble. Consistent, covered by leg 2, and a shape of the two node types rather than a defect — none of the three filing classes. Successor: none known.",
"noted, not filed: bubbleToParent's per-outcome #4632 grading (the stranded error arm versus the functional warn arm) is unreached on a refusal, because a refused parent answers success true. That is correct — a refusal is not a degradation — but it means the strand-recording arm stays exercised only from the completion side, by the existing subflow-bubble-strand-log-level pin. Observation, not a defect. Successor: none known.",
"noted, not filed: the docs-drift bot named two RELEASE-OWNED pages (content/docs/releases/v16.mdx and v17/17-0.mdx) as naming AutomationEngine. Read-only by AGENTS.md; nothing in them is falsified by this change (no rename, no removal, no advertised behaviour withdrawn), and no edit was made. Successor: the release seat, at release time."
]
}
ACCEPT — PR #19158, reviewed against GitHub and the tree, ⛔ not against the report
domain:services seat (#6021) · session_019hBqDVrwbijUCoK9qsss2E · R1 · written 2026-09-19T01:30Z. Reviewed head b2203ba3b7.
Checklist — each line a reading this seat took
item
verdict
PR shape
draft ✓ · base main ✓ · first line Fixes #18714 ✓ · Clause-②: noline-initial ✓
path surface (/pulls/19158/files)
3 files, all under packages/services/service-automation/src/ + .changeset/ — ⛔ nothing outside; ⛔ zero packages/spec
clause-② declaration leg
⭐ verified independently, ⛔ not accepted on the declaration: src/index.ts is not in the diff, and ChildRunRefusal / resumeInternal / bubbleToParent each resolve 0 times in the package entry. Control on the same instrument: that entry carries 26export lines ⇒ the zeros are readings. Nothing published moves.
--pair 19158
exit 0 — both carriers agree on no, no widening tell
CI (latest run per name)
31 success · 3 skipped · 0 red · 0 running; skips are the familiar conditional set (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in))
report location
os-dev-report on the issue (5738255926) ✓ · mcp_calls0 · api_writes 2, exactly the budget ✓
🔴 The acceptance that mattered: two legs, two pins — triage's non-negotiable
Triage's instruction was verbatim 「另起一张 PR,并让两条腿各有一条能变红的钉」. Checked structurally, ⛔ not on the report's word: the suite is 11 tests laid out as defect leg 1 / control leg 1 / defect leg 2 / control leg 2 — each leg has its own defect block and its own control.
The two ablations produce genuinely different signatures, which is what makes them two measurements rather than one restated:
ablation
signature
delete the delegated-leg detection
expected undefined to be 'refused' — the refusal is lost, fail-open
delete the up-bubble of the refusal
expected 'paused' to be 'refused' + hasSuspendedRun true — the parent is leaked
⇒ wrong-answer versus leaked-run. Exactly the two failure modes the card describes, and each ablation left the other leg's assertions and both controls green.
⭐ The disclosure that earns the most trust here is the one against the deliverer's own interest: the dev declared that the two legs share one throw site, so a mutation there would redden both pins — and that the two-pin property therefore has to be read against the detection arms, which is what the ablations mutate. ⛔ It did not let the shared chokepoint pass as though the pins were independent everywhere. It also annotated two assertions as green-on-both-sides so no later reader mistakes them for pins.
Zone-2 assumptions — both answered, one by falsifying the question
Assumption 1 (the blocker doesn't change this card): confirmed by re-measurement, ⛔ not carried over — isRegionSuspensionRefusal still has 3 sites, none on the resume or up-bubble path.
Assumption 2 (the new :10111 re-throw adjacency — asked as a question, ⛔ not a fence): answered, and the answer is no. The two predicates are structurally disjoint — FlowRegionSuspensionRefusalError is an Error subclass branded with a registered Symbol.for and carries no __flowRefused; FlowRefusalSignal is a non-Error sentinel carrying __flowRefused and no symbol. Neither detector can answer true for the other's value. ⇒ ⭐ the right outcome for a question dispatched as a question: it was investigated and closed with a mechanism, ⛔ not deferred and ⛔ not turned into speculative work.
patch, not minor — nothing published moves, which is the same fact that makes Clause-②: no honest. Consistent.
Out-of-scope findings — placed
Three noted, not filed, each with its successor named, and this seat agrees none meets the three filing classes: the subflow:-only correlation special-case (a shape of the two node types, covered by leg 2) · bubbleToParent's per-outcome grading being unreached on a refusal (correct — a refusal is not a degradation) · the docs-drift bot naming two release-owned pages, correctly left alone since nothing in them is falsified and content/docs/releases/** is ⛔ never edited in a code PR.
domain:services seat (#6021) · session_019hBqDVrwbijUCoK9qsss2E · R1 · written 2026-09-19T02:02Z.
probe
value
(#19158) in the commit log
1
positive control (#19136)
1
negative control (#99999)
0
the new two-leg pin file on main
1
ChildRunRefusal sites in engine.ts
6
⭐ Clause-②: no still holds AFTER landing — the refusal type in the package entry
0 (control: entry carries 26export lines)
negative control on a bogus symbol
0
⇒ the delivery is on main, and the clause-② declaration is true of the landed code, ⛔ not merely of the declaration.
Half-state cleared
Closed completed still carrying pm:dispatched + assignee. Now priority:p1, domain:services, 0 assignees. ⛔ Cleared with this note, ⛔ not silently.
⚠️Fourth card in a row to need this. A closing keyword closes the issue and leaves the board exactly as it was — this is now written into the seat post's standing facts so the next seat does not re-learn it four times.
What landed
A child flow that durably pauses and only then refuses now rolls that refusal up to its parent on both resumed legs — the delegated resume (which used to lose it fail-open, reporting completed and walking the out-edge) and the up-bubble (which used to strand the parent paused in listSuspendedRuns() forever). Both arms hand the refusal to one throw site and terminate through the existing finishRefusedRun chokepoint, so no second unwinding protocol and no new vocabulary were minted.
⭐ Triage's non-negotiable — 「两条腿各有一条能变红的钉」 — was met and measured: the two ablations produce different signatures (expected undefined to be 'refused' = lost; expected 'paused' to be 'refused' + a live hasSuspendedRun = leaked), each leaving the other leg green.
Activity
docs(service-automation): name the follow-up card for the resumed leg…
huangyiirene commentedon Sep 18, 2026
Deferred this round — serial behind #18881, and the fold-or-serial question answered explicitly
domain:servicesseat (#6021) ·session_019hBqDVrwbijUCoK9qsss2E· R1 · written 2026-09-18T21:47Z. ⛔ Report-only: no label written, no claim taken, this card keepspm:queueand stays dispatchable. Board readings taken at this fire.Why this is written now rather than when the card is picked up
This card and #18881 are both p1, both
pm:queue, and both land inpackages/services/service-automation/src/engine.ts. That is a shared hot file between two queued cards, so the protocol requires this seat to answer fold or serial out loud rather than let a default happen. #18881 was dispatched this fire; this card was not. The reasoning is recorded here so the next seat inherits a decision, ⛔ not a silence.Fold-or-serial — SERIAL, and two of the five gates fail
loop/parallel/try_catchdurably suspends and the run reports success with nothing run. This card is a resume-leg rollup defect: a child that pauses and then refuses is either lost fail-open (delegated resume) or strands its parent inpaused(up-bubble). Same file, same subsystem — ⛔ not the same defect and not the same repair. Same keyword is ⛔ not gate ①.service-automation, bothengine.ts.⇒ Two independent gate failures. ⛔ Not foldable. They run serial, #18881 first.
🔴 The re-pricing question the next dispatch must answer BEFORE this card goes out
⛔ Do not dispatch this card on the estimate that stands today. When #18881's PR lands, its dev's report must be read against this card with the mandatory question:
This is not a formality here — #18881 adds a named structured failure when a contained node durably suspends. If that failure fires on the shape this card's probe drives, part of this card may already be closed by it. ⭐ The seat's own guess is that it does NOT — this card's probe is
subflow→ pausing node → refusingend, which need not sit inside a structured region at all — but that is a guess, ⛔ not a reading, and it is recorded here as a falsifiable assumption rather than a premise.Readings this seat took at 2026-09-18T21:40Z that this card should not have to re-take
endINSIDE a structured region propagate out and terminate the run, or stay a loud refusal at the region boundary? #18112 isclosed/completed. The card warns it 「may intersect the region/rethrow territory that [Decision] service-automation: should a refusingendINSIDE a structured region propagate out and terminate the run, or stay a loud refusal at the region boundary? #18112's ruling deliberately left closed (option B not implemented, no container taught to rethrow)」 — ⇒ that ruling is closed and must be read before this card is dispatched, exactly as the card asks.mapinside a contained region leaves its progress state behind — later loop iterations skip items and the exhausted map returnssuccesshaving run nothing #15646 is stillopen, and its spec half landed as78436637(feat(spec)!: a structured region body refuses a pause-capable node and an 'end' node #18688) onorigin/main.subflowchild that endsrefusedis rolled up by the parent as an ordinary success — the refusal reaches nobody #18110/[finding] service-automation:maprolls arefusedchild up as an ordinary success too — the same fail-open shape as #18110, second file #18555 / PR fix(service-automation): on the synchronous path, a child run that refuses stops its parent, insubflowand inmapalike #18706, is merged as5762eaf7.subflowand inmapalike #18706 …… 另起一张 PR,并让两条腿各有一条能变红的钉」 ⇒ two legs, two pins. A delivery that reddens only one leg is incomplete on its face.Generated by Claude Code
huangyiirene commentedon Sep 19, 2026
Claim: PM loop round 1
Session:
session_019hBqDVrwbijUCoK9qsss2EBranch:
claude/issue-18714-resumed-leg-refusal-rollupWorktree:
objectstack-issue-18714Domain:
domain:servicesSeat:
domain:services#1File surface:
packages/services/service-automation/src/+.changeset/(stop on breach; explain in the report)Container & model:
M,mode:subagent,model: default judgment tier (opus)— this act'sdispatch-gates.mjs --repo objectstack-ai/objectstack --tier packages/services/service-automation/src/engine.tsreturned verbatim 「no path-derived mandate: the surface hits none of the 3 declared glob(s) … floor sonnet · default opus · ceiling fable」. Kept at default: the card names two legs and ⛔ does not prescribe the fix, so the mechanism is a live design judgment.Clause-②: no
Thread-read: 5736595238
Serial constraints cleared:
the hot file is engine.ts. Its previous holder #18881 MERGED as PR #19140 (verified on origin/main with controls), which is what unblocks this card. Lane pm:dispatched now = #18336 (this seat's, surface core/security + plugin-auth + plugin-security + organizations test ⇒ disjoint from service-automation) and #16506 (⛔ another seat's, on src/email-service.queue-delivery.test.ts ⇒ disjoint). ⛔ No sibling card pins behaviour this one asserts.🔴 Re-pricing against the card that was blocking it — the mandatory question, answered from measurement
⭐ Unaffected. ⛔ Not a guess this time — when this card was deferred, this seat wrote that its probe 「need not sit inside a structured region at all」 and explicitly marked that a guess, not a reading. It is now measured on
origin/mainafter #19140 landed:isRegionSuspensionRefusalcall sites inengine.ts:214), the one-refusal-one-failure suppression (:9528), and a re-throw (:10111)bubbleToParentsites:6689), defined at:7067; the refusal arm's structure is unchanged⇒ #18881 fires inside
runRegion/executeNode. Neither of this card's two legs is touched, so its cost, its shape and its two-pin acceptance all stand as filed. ⛔ The estimate is ⛔ not carried over from filing time — it is re-taken here.engine.ts:10111now re-throws onisRegionSuspensionRefusal. A run that resumes into a structured region can now meet that error type on a path this card is rewriting. ⇒ check whether the resume legs need to handle it, and say what you found. This seat did ⛔ not establish that they do.Standing constraints carried from the card and its triage
subflowand inmapalike #18706 …… 另起一张 PR,并让两条腿各有一条能变红的钉」 ⇒ two legs, two pins. A delivery that reddens only one leg is incomplete on its face.endINSIDE a structured region propagate out and terminate the run, or stay a loud refusal at the region boundary? #18112 isclosed/completed(measured 2026-09-18T21:40Z). The card warns it 「may intersect the region/rethrow territory that [Decision] service-automation: should a refusingendINSIDE a structured region propagate out and terminate the run, or stay a loud refusal at the region boundary? #18112's ruling deliberately left closed」 ⇒ read that ruling before choosing a mechanism.subflowchild that endsrefusedis rolled up by the parent as an ordinary success — the refusal reaches nobody #18110/[finding] service-automation:maprolls arefusedchild up as an ordinary success too — the same fail-open shape as #18110, second file #18555, PR fix(service-automation): on the synchronous path, a child run that refuses stops its parent, insubflowand inmapalike #18706) is merged as5762eaf7.Written by this seat at 2026-09-19T00:40Z. Seat post: #6021.
Generated by Claude Code
wip: #18714 resumed-leg refusal rollup
huangyiirene commentedon Sep 19, 2026
os-dev-report
{
"issue": 18714,
"status": "done",
"branch": "claude/issue-18714-resumed-leg-refusal-rollup",
"pr": "#19158",
"session": "session_019hBqDVrwbijUCoK9qsss2E",
"premise_still_valid": true,
"summary": "Both resumed legs of the subflow chain now roll a child run's REFUSAL up to its parent. The delegated-resume block (engine.resume(parentRunId)) records the refusal when the child it drives answers status 'refused'; bubbleToParent gains a refusal argument so a child resumed directly (engine.resume(childRunId)) also resolves its parent instead of leaving it parked. Both arms hand the refusal to ONE throw site inside the resume's traversal try-block, past claimAdvance/forgetSuspendedRun and before the traversal, which raises the existing internal FlowRefusalSignal so the run terminates through the single finishRefusedRun chokepoint: parent row reads 'refused', carries the child's rendered refusalMessage, fires no successMessage, walks no out-edge, and keeps the child's #4354 rollup. Multi-level chains resolve by the same induction completions already use. ISSUE BODY: no sanitizer truncation — the body ends on its intact dedupe-words section and footer, and both comments plus the PM claim read whole. CLAIM VERIFIED: newest Claim: comment 5737929003 names this branch. ASSIGNEE: set by the PM to huangyiirene before dispatch; this run never wrote it.",
"tests": "ABLATION (two legs, one at a time, via scripts/ablation-replace.mjs — anchor-must-hit, on-disk blob proof, proven restore). The pins resolve ./engine.js from package SOURCE (in-package relative import, no dist on the resolution path), which the reddening itself demonstrates. LEG 1, delete the delegated-leg detection: tool printed 'anchor 1 to 0, blob 90ccd6c29edf to fe1d7763fc67'; result 4 failed | 7 passed; the four reds are all leg-1: "expected undefined to be 'refused'" (twice), "expected undefined to be 'Refused: vip is not eligible'", and "expected [ 'child-work', 'downstream' ] to deeply equal [ 'child-work' ]"; every leg-2 assertion and both controls GREEN. LEG 2, delete the up-bubble of the refusal: 'anchor 1 to 0, blob 90ccd6c29edf to 6098b376281a'; result 3 failed | 8 passed; the three reds are all leg-2: "expected 'paused' to be 'refused'" (twice, on the parent's run row and on its summary) and "expected true to be false" (hasSuspendedRun on the parent); every leg-1 assertion and both controls GREEN. Two DISTINCT failure signatures — wrong-answer fail-open versus leaked-run — not one measurement restated. Both restore legs printed 'ok restored: blob == HEAD (90ccd6c29edf) and
git diff HEADis empty', and git status --porcelain was empty after each. TWO leg-2 assertions are measured green on BOTH sides and are annotated in the file as such, so no reader mistakes them for pins: the downstream-did-not-run one, kept as the pin on the WRONG fix of bubbling the refusal as a completion; and the child-envelope invariance one. SUITES: pnpm --filter @objectstack/service-automation test printed 'Test Files 140 passed (140)' and 'Tests 1671 passed (1671)', exit 0 — so the synchronous leg (#18110 and #18555), the region refusal (#18881) and the retryable delegated resume-bag codes (#14379) keep their pins. pnpm --filter @objectstack/service-automation typecheck exit 0 including check:test-typecheck ('0 file(s) / 0 error(s)'). New pin file alone: 'Test Files 1 passed (1)' and 'Tests 11 passed (11)'. All heavy runs went through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-18714; every verdict read off its printed 'VERDICT command-exit' line, never a bare exit variable after a pipe.","gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (NO paths passed; change set derived from merge base 6819dcb) yielded 59 commands. ALL 59 RUN, exit codes landed to disk, reconciled with --ran: 'dispatch-gates --ran: 59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED (a DERIVED zero — all 59 recorded an exit code and none of them is 3)', exit 0. Two gates first answered exit 3 PREREQUISITE NOT MET (pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt — both demand a built closure); after 'pnpm exec turbo run build --filter=./packages/* --filter=./packages// --concurrency=2' (72/72 tasks successful, exit 0) both were re-run and answered exit 0 ('104 published require entry point(s) across 67 package(s) load' and '4 ledger entr(ies) re-measured, 53 raw tsc error(s) total, none above its recorded number'). REPO-LEVEL SCAN, run whole and NOT narrowed: pnpm lint (eslint . --no-inline-config) exit 0 on the final commit b2203ba. pnpm check:nul-bytes green, plus a hand grep -naP over the three changed files for control bytes: zero hits. CLAUSE-2: node scripts/pm/check-clause2-carriers.mjs --pair 19158 exit 0, verdict 'the clause-② declaration is readable in the fixed spelling and both carriers agree, and its diff carries no widening tell' — card claim and PR body both DECLARED 'no'. NO needs:contract-review is owed on this PR and none is present on it. REMOTE CI on PR head b2203ba: 34 check runs completed, 31 success / 3 skipped / 0 failure — all SEVEN required contexts GREEN (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard). Not a substitute for CI's own account: the 51 artifact-roster families, 11 declared-wide families, 5 path-scheduled CI jobs and the always-runs tail sit outside the derived 59, as the tool states.",
"line_budget": "NOT APPLICABLE — the diff touches no skills/** package and no published SKILL.md, so no line ratchet is owed and none was paid. Diff size for the record: 3 files, +525/-4 versus origin/main — engine.ts +184/-4 (two detection arms, one shared throw site, one module-private interface, docblocks), resumed-leg-refusal-rollup.test.ts +318 (new), .changeset/18714-resumed-leg-refusal-rollup.md +27 (new). The auto-labeler put size/l on the PR; that is another actor's write and was left alone.",
"deviations": "NONE that narrow verification: the repo-level lint ran whole rather than narrowed, and all 59 derived gate families ran with zero NOT-MEASURED. Two deliberate, declared choices worth the seat's eye. (1) The two resumed legs share ONE throw site and one terminal chokepoint, so a mutation AT THE SHARED SITE would redden both pins. The legs are kept independently dissolvable by their own detection arms, which is exactly what the two ablations above mutate; the two-pin acceptance should be read against those, not against the shared site. (2) The changeset is a patch, not a minor: nothing published moves (ChildRunRefusal is module-private, resumeInternal and bubbleToParent are private), which is also why Clause-2 is 'no'. No label was written from here, per the dispatch's write budget. No PR body PATCH was issued — the body was written once, at creation, and read back byte-identical. FILE SURFACE HELD: packages/services/service-automation/src/ plus .changeset/ and nothing else; zero packages/spec.",
"files_changed": [
"packages/services/service-automation/src/engine.ts",
"packages/services/service-automation/src/resumed-leg-refusal-rollup.test.ts",
".changeset/18714-resumed-leg-refusal-rollup.md"
],
"zone2_findings": "ASSUMPTION 1 (the blocker #18881 / PR #19140 does not change this card) — CONFIRMED by re-measurement, not carried over. On origin/main isRegionSuspensionRefusal has exactly 3 sites in engine.ts (import :214, the one-refusal-one-failure suppression :9528, the inner-boundary rethrow :10111); on this branch the same 3, shifted to :214 / :9704 / :10287 by my insertions. NONE is on the resume or up-bubble path. bubbleToParent was called on the completion path only (:6689 on main); my change adds exactly one more call site and changes nothing about the existing one. Negative control: a bogus token grep returned 0 hits, exit 1. ASSUMPTION 2 (the NEW :10111 rethrow adjacency — a question, not a fence) — ANSWERED: the resume legs do NOT need to handle it. The two predicates are structurally disjoint. FlowRegionSuspensionRefusalError is an Error subclass branded with a registered Symbol.for and carries no __flowRefused; FlowRefusalSignal is a non-Error sentinel carrying __flowRefused and no symbol. Neither isRefusalSignal nor isRegionSuspensionRefusal can answer true for the other's value. A run that resumes INTO a structured region and meets the region refusal still falls to resumeInternal's generic failure arm and is failed, which is #18881's intended outcome; my arms sit above that and never intercept it. Held by the full package suite staying green, region-durable-suspension-refusal and nested-region-parity included. ASSUMPTION 3 (the card's line numbers have drifted) — CONFIRMED, and re-located by behaviour rather than by number: the delegated-resume branch the card cited at :6266-6350 is the correlation.startsWith('subflow:') block, and the bubble arm it cited at :6676 / :6696 is the completion-path bubbleToParent call and the isRefusalSignal catch arm beside it. ASSUMPTION 4 (whether to reuse PR #18706's FlowRefusalSignal channel) — MEASURED and REUSED, and it is the right channel: both existing producers of a refusal already unwind through it to finishRefusedRun, the resume path already carries an isRefusalSignal catch arm, and reusing it means the resumed legs mint no second unwinding protocol, no second terminal exit and no new vocabulary.",
"issue_18112_reading": "READ IN FULL — the card plus its single ruling comment (hotlong, 2026-09-17, director batch #146 item 2, maintainer 「146 同意」). What it CLOSED: letter A — a refusing end inside a structured region stays a loud failure at the region boundary, plus the authoring-time refusal folded into #15646. What it deliberately left CLOSED: option B — teaching try_catch (and parallel / map) to RETHROW the refusal instead of catching it, and the paired decision about the in-flight siblings of a parallel gateway; B 'reopens only with #3267's 「支持」, for both signals together'. MY MECHANISM STAYS OUTSIDE IT on all three counts: (a) it teaches no container to rethrow — runRegion, try_catch, parallel, loop and the map executor are untouched by this diff; (b) it decides nothing about parallel siblings; (c) it adds no engine surface. It also cannot reach that territory by construction: a region body runs synchronously inside the enclosing run and cannot carry a durable pause at all (#3267 禁, enforced at authoring by #18688 and at runtime by #18881), so the shape this card fixes — a run PARKED at a subflow or map node awaiting a child — can never be inside a region body. The two arms are on the top-level durable-resume machinery only.",
"mcp_calls": "0 — no MCP GitHub tool was called, read or write. Every GitHub read and write went through the REST proxy with curl and GITHUB_TOKEN.",
"api_writes": "2 — POST /repos/objectstack-ai/objectstack/pulls (draft PR #19158) and POST /repos//issues/18714/comments (this report). No label write, no PATCH, no POST /issues. The three git pushes to the feature branch are git, not REST writes.",
"open_questions": [],
"out_of_scope_findings": [
"noted, not filed: the delegated-resume block special-cases the 'subflow:' correlation only, so a map parent reaches its child's outcome exclusively through the up-bubble. Consistent, covered by leg 2, and a shape of the two node types rather than a defect — none of the three filing classes. Successor: none known.",
"noted, not filed: bubbleToParent's per-outcome #4632 grading (the stranded error arm versus the functional warn arm) is unreached on a refusal, because a refused parent answers success true. That is correct — a refusal is not a degradation — but it means the strand-recording arm stays exercised only from the completion side, by the existing subflow-bubble-strand-log-level pin. Observation, not a defect. Successor: none known.",
"noted, not filed: the docs-drift bot named two RELEASE-OWNED pages (content/docs/releases/v16.mdx and v17/17-0.mdx) as naming AutomationEngine. Read-only by AGENTS.md; nothing in them is falsified by this change (no rename, no removal, no advertised behaviour withdrawn), and no edit was made. Successor: the release seat, at release time."
]
}
Generated by Claude Code
huangyiirene commentedon Sep 19, 2026
ACCEPT — PR #19158, reviewed against GitHub and the tree, ⛔ not against the report
domain:servicesseat (#6021) ·session_019hBqDVrwbijUCoK9qsss2E· R1 · written 2026-09-19T01:30Z. Reviewed headb2203ba3b7.Checklist — each line a reading this seat took
main✓ · first lineFixes #18714✓ ·Clause-②: noline-initial ✓/pulls/19158/files)packages/services/service-automation/src/+.changeset/— ⛔ nothing outside; ⛔ zeropackages/specsrc/index.tsis not in the diff, andChildRunRefusal/resumeInternal/bubbleToParenteach resolve 0 times in the package entry. Control on the same instrument: that entry carries 26exportlines ⇒ the zeros are readings. Nothing published moves.--pair 19158no, no widening tellBuild Docs,Console Pin Gate,Packed-tarball smoke (opt-in))os-dev-reporton the issue (5738255926) ✓ ·mcp_calls0 · api_writes 2, exactly the budget ✓🔴 The acceptance that mattered: two legs, two pins — triage's non-negotiable
Triage's instruction was verbatim 「另起一张 PR,并让两条腿各有一条能变红的钉」. Checked structurally, ⛔ not on the report's word: the suite is 11 tests laid out as
defect leg 1/control leg 1/defect leg 2/control leg 2— each leg has its own defect block and its own control.The two ablations produce genuinely different signatures, which is what makes them two measurements rather than one restated:
expected undefined to be 'refused'— the refusal is lost, fail-openexpected 'paused' to be 'refused'+hasSuspendedRuntrue — the parent is leaked⇒ wrong-answer versus leaked-run. Exactly the two failure modes the card describes, and each ablation left the other leg's assertions and both controls green.
⭐ The disclosure that earns the most trust here is the one against the deliverer's own interest: the dev declared that the two legs share one throw site, so a mutation there would redden both pins — and that the two-pin property therefore has to be read against the detection arms, which is what the ablations mutate. ⛔ It did not let the shared chokepoint pass as though the pins were independent everywhere. It also annotated two assertions as green-on-both-sides so no later reader mistakes them for pins.
Zone-2 assumptions — both answered, one by falsifying the question
isRegionSuspensionRefusalstill has 3 sites, none on the resume or up-bubble path.:10111re-throw adjacency — asked as a question, ⛔ not a fence): answered, and the answer is no. The two predicates are structurally disjoint —FlowRegionSuspensionRefusalErroris anErrorsubclass branded with a registeredSymbol.forand carries no__flowRefused;FlowRefusalSignalis a non-Errorsentinel carrying__flowRefusedand no symbol. Neither detector can answer true for the other's value. ⇒ ⭐ the right outcome for a question dispatched as a question: it was investigated and closed with a mechanism, ⛔ not deferred and ⛔ not turned into speculative work.endINSIDE a structured region propagate out and terminate the run, or stay a loud refusal at the region boundary? #18112 read in full and the mechanism stays outside it on three counts — no container taught to rethrow, nothing decided about parallel siblings, no engine surface added. Plus a structural argument: a region body runs synchronously and cannot carry a durable pause at all, so the parked-at-a-subflow shape this card fixes can never be inside one.Changeset
patch, notminor— nothing published moves, which is the same fact that makesClause-②: nohonest. Consistent.Out-of-scope findings — placed
Three
noted, not filed, each with its successor named, and this seat agrees none meets the three filing classes: thesubflow:-only correlation special-case (a shape of the two node types, covered by leg 2) ·bubbleToParent's per-outcome grading being unreached on a refusal (correct — a refusal is not a degradation) · the docs-drift bot naming two release-owned pages, correctly left alone since nothing in them is falsified andcontent/docs/releases/**is ⛔ never edited in a code PR.⇒ ready + enqueued.
Generated by Claude Code
huangyiirene commentedon Sep 19, 2026
✅ Landed — PR #19158 MERGED, verified on
origin/main, half-state cleareddomain:servicesseat (#6021) ·session_019hBqDVrwbijUCoK9qsss2E· R1 · written 2026-09-19T02:02Z.(#19158)in the commit log(#19136)(#99999)mainChildRunRefusalsites inengine.tsClause-②: nostill holds AFTER landing — the refusal type in the package entryexportlines)⇒ the delivery is on
main, and the clause-② declaration is true of the landed code, ⛔ not merely of the declaration.Half-state cleared
Closed
completedstill carryingpm:dispatched+ assignee. Nowpriority:p1,domain:services, 0 assignees. ⛔ Cleared with this note, ⛔ not silently.What landed
A child flow that durably pauses and only then refuses now rolls that refusal up to its parent on both resumed legs — the delegated resume (which used to lose it fail-open, reporting
completedand walking the out-edge) and the up-bubble (which used to strand the parentpausedinlistSuspendedRuns()forever). Both arms hand the refusal to one throw site and terminate through the existingfinishRefusedRunchokepoint, so no second unwinding protocol and no new vocabulary were minted.⭐ Triage's non-negotiable — 「两条腿各有一条能变红的钉」 — was met and measured: the two ablations produce different signatures (
expected undefined to be 'refused'= lost;expected 'paused' to be 'refused'+ a livehasSuspendedRun= leaked), each leaving the other leg green.Generated by Claude Code
fix(service-automation): a durable suspension inside a structured reg…
fix(service-automation): a resumed child's refusal rolls up on both l…