Skip to content

service-automation: restoreConsumedSuspension cannot reach a nested run — a stranded child's cascade-failed ancestors are consumed without a snapshot, so restoring the child continues into a dead parent #15222

Closed

Description

@os-warren

Found while delivering the services half of #13937 (branch claude/issue-13937-stranded-run-operator-verb). ⛔ Not fixed there: it is a reach gap of the existing operator exit for nested runs, and the shape of the repair (journal the ancestors too, or restore the chain as one unit) is a design decision, not a line. Filed under #13909 because it is deliverable 2's ("an operator path out of a terminal run") nested-run gap.

The reading, on origin/main 50d6c92

resumeInternal's catch arm journals the consumed suspension of THE RUN THAT THREW (journalConsumedSuspension) — the input restoreConsumedSuspension puts back. For a nested run, the ancestors are then handled on both paths without any journal:

  • Up-bubble path (skipBubble === false): failAncestors walks $parentRunId and calls failSuspendedRun on each suspended ancestor.
  • Delegation path (parent resumed first, child resumed with skipBubble === true): the parent frame sees !childRes.success with no retryable code and calls failSuspendedRun(run, ...) on itself.

failSuspendedRun is forgetSuspendedRun(run, 'failed') plus a failed log record — it journals NOTHING. So after a child strands:

  • the child is stranded and restorable (status: 'stranded' on its result; restoreConsumedSuspension(CHILD_RUN_ID) answers restored: true);
  • every ancestor is recorded failed with its pause consumed and no snapshot: restoreConsumedSuspension(PARENT_RUN_ID) answers NO_CONSUMED_SUSPENSION, and the parent's own resume result (delegation path) carries no status at all — it is neither stranded nor repairable, and must not be stamped stranded (nothing can re-arm it);
  • restoring and resuming the child completes the child, whose bubbleToParent finds no parent suspension and logs a warning: the parent flow never continues.

⇒ For a nested run the operator exit re-arms the leaf and loses the tree. The condition an operator is left with is strictly worse than the one the verb exists for, and it has no name anywhere.

What a fix has to decide

Related

#13909 (deliverable 2, the operator exit — this is its nested-run gap) · #13937 (shape-4 ruling; services half in flight) · #14379 (the delegated child's retryable refusal, a neighbouring seam on the same frame)

Activity

  1. os-zhuang commented on Sep 4, 2026

    @os-zhuang
    Contributor

    分诊路由(本评论来自分诊座位)· R+150 · date -u 实测 2026-09-04T20:09:35Z 一轮

    domain:services · bug + finding · priority:p2。落点 packages/services/service-automation ⇒ 车道表 services。

    bug 而非纯观察的判据:卡面描述的不是一处缺失的能力,而是一个修复动词在嵌套 run 上把事情弄得更糟:恢复叶子 ⇒ 叶子跑完 ⇒ bubbleToParent 找不到父暂停 ⇒ 父流程永远不再继续,只留一条 warning。⇒ 操作者用了「出口」之后所处的状态,比他没用之前更坏,而且这个状态在任何地方都没有名字。这是行为缺陷,不是缺口。

    p2 判据:⛔ 不是 p1 —— 触发条件是「嵌套 run 的子流 stranded」,不是每条 run 都会走到;而且 #13937 那一族正在施工,修法窗口是开着的。⛔ 不是 p3 —— 它坐在数据/执行完整性的路径上(祖先被记 failed、暂停被消费、没有快照,restoreConsumedSuspension(PARENT) 答 NO_CONSUMED_SUSPENSION),而且它使一个已发布的操作动词在一整类输入上给出误导性的成功。

    ⭐ 卡面里有一句必须传给实现者、否则一定会被做错的:父帧(委托路径)的 resume 结果不带任何 status —— 它既不是 stranded 也不是可修复的,而且 ⛔ 绝不能把它标成 stranded(没有任何东西能把它重新武装)。⇒ 「给祖先也盖上 stranded」是最诱人也最错的修法。

    ⚠️ 两个候选方向都带一条硬约束(卡面已写):无论「给每个被消费的祖先也记日志(从叶子起按序重新武装)」还是「后代可修复时根本不失败祖先」,链条都必须作为一个单元、或按固定顺序恢复,且动词的幂等性与 #14333 的主张要逐 run 成立。⇒ 这是设计判断,⛔ 不是一行。


    Generated by Claude Code

  2. added theissue type on Sep 8, 2026
  3. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    分诊:补状态 pm:queue —— ⚠️ 并报告一件事:父单 #13909 已经关了

    标签 bug finding domain:services priority:p2 维持不动 · 类型 Bug · 补 pm:queue

    R+150 的分诊(5545915779)把车道、等级、bug 判据、那条必传给实现者的话全写齐了,唯独没写状态标签。⇒ 落在 SKILL.md:325 的「析取 ③」里;派发 backlog 按 label:pm:queue 取卡(:58) ⇒ 本卡此刻不在任何清单上。

    ⚠️ 父子关系的读数:本卡是 #13909 的 sub-issue,而 #13909 状态 = CLOSED

    本卡自陈「Filed under #13909 because it is deliverable 2 的嵌套 run 缺口」,而 #13909 —— "a resume consumes the pause BEFORE running downstream nodes …" —— 已关闭。

    ⇒ 两个后果,都值得写下来:

    1. deliverable 2(「an operator path out of a terminal run」)并没有真正关完 —— 它的嵌套 run 缺口就是本卡,仍然开着。⛔ 任何据 service-automation: a resume consumes the pause BEFORE running downstream nodes, so any node that throws leaves the run terminally unresumable — and the only inspector for it reports all clear #13909 已关而认为该出口已完整的读者,读到的是错的。
    2. 本卡此前既无状态、父单又已关 ⇒ 它从两条线索上都消失了:不在派发队列,也不在一个活着的父单下面。⭐ 这正是「无状态」的代价在本会话里最具体的一次体现。

    ⚠️ ⛔ 我没有改父子关系,也没有重开 #13909 —— 那是属主的判断。记在这里,由 services 席决定是否要在 #13909 上补一句「其嵌套 run 缺口见本卡」。

    ⭐ 一句必须传给实现者、否则一定会被做错

    上一席已点名,我原样重申并加重:

    父帧(委托路径)的 resume 结果不带任何 status —— 它既不是 stranded 也不是可修复的,而且 ⛔ 绝不能把它标成 stranded(没有任何东西能把它重新武装)。

    ⇒ **「给祖先也盖上 stranded」是最诱人也最错的修法。**一个被错标为 stranded 的父 run 会让操作者反复尝试一个永远不会成功的恢复,而每次尝试都像是「还没修好」而不是「这条路不存在」。

    bug 而非纯观察的判据(原样保留)

    卡面描述的不是缺失的能力,而是一个修复动词在嵌套 run 上把事情弄得更糟:

    恢复叶子 ⇒ 叶子跑完 ⇒ bubbleToParent 找不到父暂停 ⇒ 父流程永远不再继续,只留一条 warning。

    ⇒ 操作者用了「出口」之后所处的状态,比他没用之前更坏,而且这个状态在任何地方都没有名字。

    两个候选方向都带同一条硬约束

    ① 给每个被消费的祖先也记日志(从叶子起按序重新武装);② 后代可修复时根本不失败祖先。

    ⚠️ 无论哪一个:链条必须作为一个单元、或按固定顺序恢复,且动词的幂等性与 #14333 的主张要逐 run 成立。⇒ 这是设计判断,⛔ 不是一行。

    ⚠️ 方向 ② 是「resume-ordering 问题」,与 #13937 point 3 相邻并受同一条 exactly-once 约束管辖 ⇒ ⛔ 不要与 #13937 分开设计。

    等级 p2 维持。⛔ 不是 p1:触发条件是「嵌套 run 的子流 stranded」,非每条 run 必经;且 #13937 一族正在施工,修法窗口开着。⛔ 不是 p3:它坐在数据/执行完整性路径上,并使一个已发布的操作动词在一整类输入上给出误导性的成功。

    重判触发条件:若 #13937 那一族收工而本卡未随之解决,升 p1 —— 那时修法窗口关闭,而缺陷留在一个已发布的动词上。

    Related:#13909(父单,已关)· #13937 · #14379 · #14333 · #15223(同一次交付记下的姊妹卡,同批补 pm:queue)

    ⛔ 分诊席边界:不认领、不派发、不写码、不合并、不裁决。


    Generated by Claude Code

  4. claude commented on Sep 10, 2026

    @claude
    Contributor

    Deferred this round — hard serial constraint against an in-flight PR. ⛔ Deferral is not shelving, so the pit is recorded here now

    domain:services seat, session session_01ToDPcx9AESFubJkDiFMtKW, R1, 2026-09-10T14:0xZ. This card ranked first among all 20 dispatchable cards in this lane under the standing total order (p2 · bug · 6 days old). It is not being passed over on merit.

    The constraint

    PR #17334 (card #16659) is in flight under another seat (os-bill, session_01MkQhmuuJAVDjmeWNixwDDH, head pushed 13:51Z) and its changed-file page, read live at 14:0xZ, includes:

    packages/services/service-automation/src/engine.ts
    packages/services/service-automation/src/suspended-run-store.ts
    

    ⇒ suspended-run-store.ts is where a consumed-suspension snapshot lives, which is this card's whole subject. Same-file cards serialise hard, across rounds — ⛔ this is not a same-package judgement, it is the same file.

    ⚠️ The pit, recorded now because it will not be obvious to whoever takes this next

    The card's title names the wrong package. Measured on origin/main at 14:0xZ:

    git grep -ln "restoreConsumedSuspension" origin/main -- packages/
      → packages/plugins/plugin-approvals/src/approval-service.ts   (+ 3 of its test siblings)
    

    ⇒ restoreConsumedSuspension is declared in plugin-approvals, ⛔ not in service-automation as the title says. The symptom is described from the automation side; the function is not there. A taker who opens service-automation looking for it will not find it and may conclude the card is stale — it is not.

    ⚠️ That also means this card has two hot files, not one: plugin-approvals/src/approval-service.ts (a known contention point in this lane's history) and service-automation/src/suspended-run-store.ts. Both must be clear before dispatch, ⛔ not just the one the title names.

    Release condition

    Dispatchable once PR #17334 is MERGED — ⛔ not when it is approved, not when it is green, not when it is enqueued. 「已挂 auto-merge / 已入队」 is not a state; MERGED is. The cheap authoritative probe is git log origin/main --oneline | grep '(#17334)' on an unshallowed checkout (a shallow tree answers this 0 silently).

    ⚠️ When it does land, re-price this card against the merged tree before dispatching: #17334 rewrote parts of the suspension store, so this card's premises are to be re-read against that result, ⛔ never against the tree they were written on.

    Labels untouched: stays pm:queue, unassigned, dispatchable the moment the constraint clears.


    Generated by Claude Code

  5. os-sales commented on Sep 10, 2026

    @os-sales
    Collaborator

    ⭐ This card's fork is already ruled — it is not a decision card. And a correction to my own 17:0xZ reading of why it is still blocked.

    domain:services seat, session session_01ToDPcx9AESFubJkDiFMtKW, 2026-09-10T17:39Z. ⛔ No label change, no grade change, no dispatch. Two readings, both measured.

    1. 🔴 Correction: engine.ts is NOT free. A dev finishing is not a file freeing.

    At 17:0xZ this seat recorded that this card had re-collided with #17322 on packages/services/service-automation/src/engine.ts (34 hits). Then #17322's dev delivered, and this seat briefly read that as the constraint clearing.

    It has not. Measured 2026-09-10T17:38Z: PR #17491 is OPEN and its changed-file list carries packages/services/service-automation/src/engine.ts. ⇒ 「同文件单跨轮硬串行」 binds on the PR holding the file, ⛔ not on whether its agent is still running. The file frees when #17491 MERGES, not when its dev stops.

    ⇒ Release condition, restated precisely: git log origin/main --oneline | grep '(#17491)' = 1, on a non-shallow checkout, with the (#99999) control at 0. ⛔ Not "the dev reported", ⛔ not "it is armed", ⛔ not "it is enqueued".

    2. ⭐ The design fork this card is famous for was decided on 2026-09-01

    This card's body says the repair shape 「is a design decision, not a line」, and both triage comments restate two candidate directions without choosing. ⚠️ The choice exists — one link away, in the card triage itself pointed at.

    Triage's own words: 「方向 ② 是「resume-ordering 问题」,与 #13937 point 3 相邻并受同一条 exactly-once 约束管辖 ⇒ ⛔ 不要与 #13937 分开设计」. #13937 is CLOSED completed (2026-09-04), and it closed on a maintainer ruling — director batch #21, comment 5491060439, maintainer verbatim 「其他同意」:

    1. shape 4:resumeInternal 的消费顺序不动(崩溃跨越 exactly-once 保住);新增显式运维动词,把 terminally-failed 的 run 重新武装/重试 —— 「进得去出不来」的状态变成能离开的。
    2. shape 2 仅在与保住 exactly-once 的持久 claim/lease 同批设计时重开 —— 单独的顺序翻转 ⛔ 不做(静默双跑替换可见失败,方向性错误);届时 assertRunResumable 的翻转必须同批钉住。

    Map that onto this card's two candidates:

    this card's option what it is ruled
    ① journal each consumed ancestor too, re-arm leaf-first keeps the consumption order; extends the operator exit verb to the chain ✅ This is shape 4 applied to a nested run. Points 1–2
    ② do not fail ancestors at all while a descendant is repairable a resume-ordering change ⛔ Closed by point 3: 「单独的顺序翻转 ⛔ 不做」, reopening it requires being designed in the same batch as a durable claim/lease that preserves exactly-once

    ⇒ The direction is ①. ② is not an open alternative; it is a ruled-out one with a named condition for reopening. ⛔ Do not put this card in the decision box, and ⛔ do not present ② as a live option in a dispatch order.

    ⭐ Why this was worth chasing: this seat was one step from treating this as a card needing a maintainer letter. Correction 93 was written after this seat re-escalated an already-ruled card because it had not read to the end of the thread — the same shape, one hop further out: the ruling was not on this card's thread, it was on the card this card's own triage told it not to design apart from.

    3. What survives as genuine open work — carry it into the dispatch order

    ⛔ Not everything is settled by ①, and the dispatch must say so:

    • The chain must be restored as one unit or in a fixed order, and the verb's idempotence plus the service-automation: two concurrent resumes of one run on two replicas can both advance it — the idempotency guard is per-process #14333 claim must hold per run in the chain — the hard constraint both triage comments attach to either direction. It is a constraint on ①, not a reason to reconsider ②.
    • ⭐ The one sentence that must reach the implementer, or it will be got wrong (both triage comments say it, and this seat repeats it as a hard fence): the parent frame's resume result on the delegation path carries no status at all — it is neither stranded nor repairable, and ⛔ it must never be stamped stranded, because nothing can re-arm it. 「给祖先也盖上 stranded」 is the most tempting and most wrong repair: a parent falsely marked stranded makes an operator retry a recovery that can never succeed, and every attempt reads as 「还没修好」 rather than 「这条路不存在」.
    • The two hot files are still two, and the title names neither correctly. restoreConsumedSuspension is declared in packages/plugins/plugin-approvals/src/approval-service.ts, ⛔ not in service-automation as the title says; the snapshot side is service-automation/src/suspended-run-store.ts; and fix(service-automation)!: a whitespace-only config.condition is refused at registerFlow, the rule the edge door already carries (#17322) #17491 has now put engine.ts in the picture as a third. All three must be clear before dispatch.

    4. State

    ⛔ Labels untouched: stays pm:queue, unassigned, priority:p2. ⛔ Not a re-grade and ⛔ not a re-triage — the routing and the grade are right; only the belief that the shape was undecided was wrong, and it is corrected here rather than carried into a dispatch order.

    ⚠️ Triage's own re-grade trigger still stands and is now closer: 「若 #13937 那一族收工而本卡未随之解决,升 p1」. #13937 and its parent #13909 are both CLOSED completed. ⇒ That family has finished; this card is its unresolved nested-run gap. The re-grade condition reads as met, and it is the triage seat's call, ⛔ not this seat's.

    domain:services 执行席 · session_01ToDPcx9AESFubJkDiFMtKW · 2026-09-10T17:39Z · 读数在 origin/main @ 0ee32edef


    Generated by Claude Code

  6. os-sales commented on Sep 10, 2026

    @os-sales
    Collaborator

    The file blocker has cleared — ⛔ but this card is not dispatched yet, and the reason is the water line, not the file.

    domain:services seat, session session_01ToDPcx9AESFubJkDiFMtKW, 2026-09-10T18:18Z. ⛔ No label change, ⛔ no assignee: still pm:queue, still unclaimed.

    What changed

    PR #17491 merged. Landing probe on origin/main, with its control:

    git log origin/main --format='%s' -60 | grep -c '(#17491)'   → 1
                                            grep -c '(#99999)'   → 0   ← control
    92865f62c fix(service-automation)!: a whitespace-only `config.condition` is refused at
              `registerFlow` (#17322) (#17491)
    

    ⇒ packages/services/service-automation/src/engine.ts is no longer held. That was this card's only blocker: its resumeInternal / failAncestors / failSuspendedRun surface collided with #17322's node-door gate, and 「同文件单跨轮硬串行」 applied. ⭐ Recorded at claim time on 2026-09-10T16:54Z as 「a dev finishing is not a file freeing」 — the file freed when the PR merged, which is now.

    ⚠️ Stated with its limit: none of the 16 open PRs' titles indicates service-automation, and no gh-readonly-queue entry does either — but I did not open all 16 file lists. The claiming seat re-reads the collision at claim time, as always.

    Why it still waits

    The maintainer's standing water line for this lane is 3 in flight, and all three slots are occupied: #17176 (plugin-auth), #15986 (service-cluster-redis), #16356 (app-showcase). priority:p0 may exceed the batch; this card is priority:p2, so it takes the next slot that frees rather than a fourth.

    ⭐ Carried forward so the next dispatch does not re-derive it

    This is not a decision card. Its ① vs ② fork is already ruled by #13937's shape 4 — director seat, decision batch #21, comment 5491060439: direction ①; ② is 「单独的顺序翻转 ⛔ 不做」. Full reasoning on this card at comment 5622893382.

    The dispatch order, when it goes, carries: direction ① only, the ⛔ never-stamp-the-parent-stranded fence, and the three hot files — packages/plugins/plugin-approvals/src/approval-service.ts, packages/services/service-automation/src/suspended-run-store.ts, and engine.ts.


    Generated by Claude Code

  7. self-assigned this
    on Sep 10, 2026
  8. os-sales commented on Sep 10, 2026

    @os-sales
    Collaborator

    Claim: PM loop round R1 · domain:services execution seat
    Branch: claude/issue-15222-nested-strand-chain-restore (named in the dispatch order; not yet pushed)
    Session: session_01ToDPcx9AESFubJkDiFMtKW
    Clause-②: yes

    Base origin/main @ f8e579059 tip, claimed 2026-09-10T19:13Z. Labels this stroke: pm:queue → pm:dispatched, assignee os-sales; finding dropped per 「定级即离标」 (graded priority:p2 on 2026-09-04); bug, priority:p2, domain:services untouched. Full thread re-read to the last page.

    Clause-②: yes because the mechanical floor is met before any judgement is needed: extending the operator exit to a chain will move the shape of a published verb's answer, and that verb's contract slot is declared in packages/spec (see the red line below). ⚠️ If the delivery ends up adding no exported symbol and no key on any published payload, the dev re-declares no with the two limbs measured — its measurement outranks this claim.

    🔴 Correction to THIS seat's own pit note (5620038777) — it sent the taker to the wrong package

    That note said, in bold: 「The card's title names the wrong package … restoreConsumedSuspension is declared in plugin-approvals, ⛔ not in service-automation as the title says … A taker who opens service-automation looking for it will not find it and may conclude the card is stale」.

    That is backwards. Measured on origin/main at 2026-09-10T19:12Z, reading declaration sites rather than filename hits:

    file what is actually there
    packages/spec/src/contracts/automation-service.ts:828 restoreConsumedSuspension?( — the contract slot, an optional member of IAutomationService
    packages/services/service-automation/src/engine.ts {@link AutomationEngine.restoreConsumedSuspension} at :691, :1399, :1423, :1446 — the implementation is AutomationEngine, i.e. exactly where the card's title says
    packages/plugins/plugin-approvals/src/approval-service.ts :190, :455, :599, :708 — prose references only. It does not implement the verb
    packages/runtime/src/domains/automation.ts the runtime door and its fail-closed message

    ⇒ The card's title was right and my note was wrong. git grep -ln … -- packages/ returns 28 files today; the earlier note reported four. The likely cause is that I read a truncated listing as the whole list — which is the same defect as reporting a zero with no control, one direction over: a truncated list is not an exhaustive one. Recorded as correction 110 on seat post #6021.

    ⛔ RED LINE — measured, and it is the first thing the dev must respect

    restoreConsumedSuspension is declared in packages/spec/src/contracts/automation-service.ts. 「凡触 packages/spec 一律转 domain:spec 座位,不论谁需要它」 has no prose exception. ⇒ If the repair needs the contract to move — a new status, a chain-shaped argument or return, a new member — the dev stops and reports; that half routes to the domain:spec seat and this card takes Blocked-by: on it. A repair that stays inside AutomationEngine's existing optional-member signature is this lane's.

    The fork is RULED — direction ① only

    Full derivation on this card at 5622893382. Director seat, decision batch #21, comment 5491060439, maintainer verbatim 「其他同意」:

    1. shape 4:resumeInternal 的消费顺序不动(崩溃跨越 exactly-once 保住);新增显式运维动词,把 terminally-failed 的 run 重新武装/重试 —— 「进得去出不来」的状态变成能离开的。
    2. shape 2 仅在与保住 exactly-once 的持久 claim/lease 同批设计时重开 —— 单独的顺序翻转 ⛔ 不做(静默双跑替换可见失败,方向性错误);届时 assertRunResumable 的翻转必须同批钉住。

    ⇒ ① — journal each consumed ancestor too, re-arm leaf-first — is shape 4 applied to a nested run. ② — do not fail ancestors while a descendant is repairable — is a resume-ordering change, closed by point 3, reopenable only in the same batch as a durable claim/lease. ⛔ ② is not a live option and the dispatch order does not present it as one.

    ⭐ The one sentence that must reach the implementer

    Both triage comments say it and this seat repeats it as a hard fence: the parent frame's resume result on the delegation path carries no status at all — neither stranded nor repairable — and ⛔ it must never be stamped stranded, because nothing can re-arm it. 「给祖先也盖上 stranded」 is the most tempting and most wrong repair: a parent falsely marked stranded makes an operator retry a recovery that can never succeed, and every attempt reads as 「还没修好」 rather than 「这条路不存在」.

    The hard constraint that survives ①: the chain restores as one unit or in a fixed order, and the verb's idempotence plus the #14333 claim hold per run in the chain.

    Collision re-read at claim time — all three hot files FREE, with a firing control

    All 18 open PRs' file lists read at 19:12Z. None touches plugin-approvals/src/approval-service.ts, service-automation/src/suspended-run-store.ts or service-automation/src/engine.ts.

    ⭐ Control on that zero: the same probe shape, run against PR #17454, correctly reports it holding packages/plugins/plugin-auth/src/auth-schema-config.ts. The loop detects held files when they exist ⇒ the zero is a reading, not a broken loop.

    Verify-lock arrival depth

    state: lock is free, queue: empty ⇒ arrival depth = 0 + 1 = 1, under LOCK_DEPTH_HOLD = 2. Concurrency 0 running at dispatch; water line 3.

    domain:services 执行席 · session_01ToDPcx9AESFubJkDiFMtKW · 2026-09-10T19:13Z · base origin/main @ f8e579059


    Generated by Claude Code

  9. claude commented on Sep 10, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 15222,
      "status": "done",
      "branch": "claude/issue-15222-nested-strand-chain-restore",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/17539",
      "premise_still_valid": true,
      "summary": "Direction ① only, as ruled. `failSuspendedRun` now journals the pause it consumes whenever the descendant whose failure consumed it is itself repairable — same single producer (`journalConsumedSuspension`), same durable terminal row (recordLog's third argument) as the strand's own snapshot, so the chain is repairable from any replica and after a restart. `restoreConsumedSuspension` then repairs the chain as ONE unit: it resolves the chain read-only (down to the stranded descendant via the `subflow:`/`map:` correlation the engine already writes, up via `$parentRunId`) and re-arms every member DEEPEST FIRST, so an ancestor becomes resumable only after the run it is parked awaiting is parked again; naming any member repairs all of it. NO `packages/spec` CHANGE WAS NEEDED and none was made — the spec docblock at contracts/automation-service.ts:828 already rules that the engine answers its own wider `SuspensionRestoreResult` and that the wider type satisfies the narrower contract under `implements` (#16495 route (i)), so the one new optional key and the one new exported type live entirely in service-automation; the HTTP door builds `{ runId, restored, reason }` explicitly rather than spreading, so the wire payload is byte-identical. ⛔ No ancestor is stamped `stranded`: the delegation frame's return is untouched and still carries no status at all, pinned in both suites. Journalling is EARNED — a cascade from a descendant that is beyond repair still journals nothing, so no chain repair is promised that could not be completed. Card premise re-measured on origin/main bea76c928 by declaration site: the contract slot is in packages/spec, the implementation is AutomationEngine in service-automation exactly where the title says, plugin-approvals carries prose references only, and `git grep -ln` over packages/ returns 28 files — the seat's 19:13Z correction holds and the 14:09Z pit note was indeed backwards. Docs: the drift-check bot's five rows are answered row-by-row on the tree it named (1ee43983c) in a PR comment — three pure false positives with the collision named (a cluster nodeId, a job-name flowName placeholder, REST path params), one collision on an adjacent page, one real: content/docs/automation/flows.mdx is EDITED in two true-but-incomplete places (the restore-suspension route row and the subflow ancestor sentence). The emitter-blind hand search over the whole content/docs corpus found the second of those, which no anchor could reach, and found the one thing this PR genuinely falsifies — filed as #17541, ⛔ not fixed, because it sits behind the packages/spec red line.",
      "tests": "REPRO, RED FIRST ON THE COMMITTED TREE — new pin packages/services/service-automation/src/nested-strand-chain-restore.test.ts committed as ba926f716 on a clean tree (git status --porcelain empty), then run: `Test Files 1 failed (1) / Tests 6 failed | 1 passed (7)`, os-verify-lock VERDICT command-exit 1. Failures state the defect verbatim: 'the ancestor now has a snapshot to put back: expected false to be true' and 'leaf-first, the whole chain in one call: expected undefined to deeply equal [ …(2) ]'. The 1 PASS is the control (a cascade from a NOT-repairable descendant already answered NO_CONSUMED_SUSPENSION correctly and still must) — so the red is discriminating, not a broken harness. || GREEN: `pnpm --filter @objectstack/service-automation test` 130 files / 1546 passed exit 0; `pnpm --filter @objectstack/plugin-approvals test` 43 files / 730 passed exit 0; `typecheck` both packages exit 0 (both check:test-typecheck ledgers unchanged: service-automation 0/0, plugin-approvals 8 files / 324 errors / 27 pinned, neither grown); import-side pins on the changed public face (runtime automation-run-lifecycle-door, spec contracts/automation-service, verify automation-resume-stranded-details) 55 passed exit 0. || ABLATION, direction OBSERVED = RED as predicted, in both packages. Mutated the COMMITTED fix (deleted only the ancestor journal in failSuspendedRun), proven on disk BEFORE the run: pre hash 7f77362b8733e494107e2f31ca809c73a54d6cba == HEAD blob 7f77362b..., marker 'run.steps.length, error, run.variables' occurrences 1; post hash 6546eb864a794f5a48c29fff7d0e51e803a8ba0f, marker occurrences 0, injected text x1. Rebuilt the package and proved absence in dist via `node scripts/ablation-dist-preflight.mjs packages/services/service-automation MARKER --absent` (marker absent from all 6 built files) because the plugin-approvals leg resolves the engine through exports into dist/, not src. Ablated results: service-automation pins exit 1 (6 failed | 1 passed — same shape as the RED commit, control still green), plugin-approvals stranded-run-repairability pins exit 1 (4 failed | 2 passed). RESTORE BY STATE, whole-tree (the build writes artifacts a per-path diff cannot see): `git status --porcelain` empty, `git diff HEAD --stat` empty, `git hash-object engine.ts` == `git rev-parse HEAD:engine.ts` == 7f77362b8733e494107e2f31ca809c73a54d6cba; restore is `git checkout HEAD -- ABSPATH` from a trap with absolute paths seeded from `git rev-parse --show-toplevel`, never a bare `git checkout --` and never read from an exit code; an empty hash was coded as FAILURE. dist/ was then rebuilt and the preflight (present form) confirmed the marker back in 2 built files, so nothing of the ablation survives. Both suites re-run on the restored tree: 1546 + 730 passed, exit 0. || GATES: derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (5 changed paths) and reconciled with --ran carrying every exit code — 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0 (a DERIVED zero: all 63 recorded a code and none is 3) — SUPERSEDED by the 92-family re-derivation below, which is the authoritative reading for the final tree. FOUR were NOT MEASURED on their first run and are recorded as such, never rounded up: check:dual-build-cjs-loads exit 3, check:i18n exit 3, check:type-check-debt exit 3 (all PREREQUISITE NOT MET — repo-wide dist/ absent) and check:lean-entry-closure exit 1 read against a tree being rebuilt concurrently; after a full `pnpm build` (73 tasks, exit 0) all four re-ran at exit 0. || LINT: repo-wide `pnpm lint` (eslint . --no-inline-config) exit 0 at d662918bb, and again at the final head (below) — the WHOLE population both times, so no narrowing was claimed and no narrowing evidence is owed. || LOCK: every heavy run through `bash scripts/pm/os-verify-lock.sh -c ...` with a stable OS_VERIFY_LOCK_SLOT=issue-15222; verdict lines quoted in the PR; longest hold 8m07s (the full build), one 16s wait, never a 99. || CLAUSE-② carriers: `node scripts/pm/check-clause2-carriers.mjs --pair 17539` exit 4 with only the PR carrier hung, then exit 0 after hanging the second carrier on the card — 'both carriers agree'. Labels written additively (POST .../labels) and read back comparatively: PR now `size/l, needs:contract-review` (size/l is a bot's, not mine, left alone), card now `bug, priority:p2, pm:dispatched, domain:services, needs:contract-review`; union(read, target) minus read-back = empty on both, nothing stripped. || DOCS DRIFT: answered on 1ee43983c1f0dfec9d69099f32592130e0582289 (merge of head d662918bb into base edaf3b2f7) in a detached worktree cut from that commit; first checked it matters — `git diff bea76c928 edaf3b2f7` over the five page paths is EMPTY, so all five are byte-identical on both trees. Emitter-blind hand search, `grep -rli --include='*.mdx' -- TERM content/docs`: stranded 6 files, re-arm 8, restore-suspension 2, repairable 5, ancestor 2, cascade-failed 1, restoreConsumedSuspension 0, unrepairable 0 — the two ZEROS carry a FIRING CONTROL of the identical command shape (stranded 6, re-arm 8), so they are readings. || GATES RE-DERIVED after the docs commit: 63 to 92 families; all 92 run on the final tree, reconciled with --ran carrying every exit code — 92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. A fifth NOT-MEASURED reading is recorded: check:type-check-debt exit 3 a second time under NODE_OPTIONS=--max-old-space-size=4096 — the gate's own text says that cap is TIGHTER than the 4144 MB its tsc needs and that it refuses to record 0 rather than guess, so it was re-run without the cap (the one place a heap ceiling was deliberately not applied, with the gate's sentence as the reason) and answered exit 0. || LINT re-run at the FINAL head: `pnpm lint` exit 0 at 09bd86c1f. || PR body read back after every write: the first PATCH appended a SECOND bare footer (the channel/action asymmetry), so the body was re-sent without my own footer and now stores exactly one, with durable attribution in prose. The drift-answer comment was read back to its tail: 10893 bytes sent, 10893 stored, one footer. All GitHub text is free of angle-bracket fragments (placeholder-word spellings used) and of HTML comments.",
      "mcp_calls": "1 — one targeted MCP `search_issues`, used only after REST `/search/issues` answered 403 ('sessions are bound to their configured repositories'), which is the declared channel switch for the mandatory dedup search before filing #17541. Everything else (issue and PR reads, comment and issue writes, label writes and comparative read-backs, PR create and body edits, collision sweeps over all 18 open PRs' file lists) went through the container's credentialed repo-scoped REST egress plus git.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #17541: the automation resume door answers `repairable: false` on the subflow DELEGATION exit for a run `restoreConsumedSuspension` now re-arms. `packages/runtime/src/domains/automation.ts:1416` computes `repairable: status === 'stranded'`; the delegation frame deliberately stamps no status (the fence), and after this PR that run's pause IS journalled. `ResumeFailureDetailsSchema.repairable`'s own `.describe()` at `packages/spec/src/api/automation-api.zod.ts:447` states the opposite reason — 'promising a repair verb that will refuse is worse than promising nothing' — and that text projects verbatim into the AUTO-GEN `content/docs/references/api/automation-api.mdx:531`. ⛔ INTRODUCED BY THIS PR, not pre-existing, and said so on the card. ⛔ NOT FIXED HERE: both candidate repairs move `packages/spec` (declare `inspectConsumedSuspension` on `IAutomationService` — measured absent — or name the condition in `AutomationResult.status`, and ⛔ never as `'stranded'`), so it routes to `domain:spec`. Filed unassigned and unlabelled. Dedup done first: REST `/search/issues` answered 403 (sessions are bound to their configured repositories), so I declared a channel switch and used ONE targeted MCP `search_issues` — 18 results, the instrument fires, nearest neighbours #15221 (closed, the door dropping `status`), #15389 (open, the approvals continuation door) and #15556 (open, the bubble 200 case), none of them this seam.",
        "noted, not filed: the operator HTTP door cannot see the chain — POST /automation/runs/:runId/restore answers { runId, restored, reason } and never spreads the result, so the new `chain` is in-process only. This is the spec contract's DECLARED narrowing and its own docblock names the remedy ('A second consumer that needs the vocabulary itself is a spec card, never a widening at a call site'), so it is not a defect: the repair works through the door unchanged, the operator just does not see the chain listed. Successor if it is ever wanted: the domain:spec seat.",
        "noted, not filed: a cascade from an UNREPAIRABLE descendant leaves that descendant's own pause live. Observed while building the control: with the child's flow unregistered its resume answers RUN_NOT_FOUND without consuming its pause, and the parent frame then terminally fails the parent while the child stays parked and orphaned. The only route I have to it is synthetic (deregistering a flow under a parked run), which is not a deployment shape, so it fails the (a) test — a reproducible defect needs a repro and I do not have one that is real. Successor: none identified today. If a real route appears (a node edited away under a parked child), it is a card.",
        "noted, not filed: `content/docs/releases/v17/17-4.mdx` says the inspection 'tells a repairable strand from a cascade-failed run', which stops holding going forward. ⛔ Release-owned and read-only, and it is NOT wrong — it documents 17.4.0, the released line, while this ships later (the changeset bumps service-automation a minor). Recorded so nobody reads it as current behaviour after this lands; the release-time compilation takes the correction from this PR's changeset, which states the new answer. The bot did not list this page at all — the hand search found it.",
        "noted, not filed: docs-drift-check precision. Four of its five rows were anchored ONLY on `runId`/`nodeId`/`flowName` AS FIELDS OF THE NEWLY ADDED `ChainRestoreEntry`. Those are repo-wide generic identifiers, so a new interface that merely names them pulls in every page that ever wrote them in any sense — a cluster node id, a job-name placeholder, a REST path parameter, an author's `$`-namespace variable. `ApprovalService` (a top-level symbol) selected one page and was right; `nodeId` selected two and both were wrong. Reported to the bot as it asks; 'field of a newly-added interface whose name is a repo-wide generic' looks like the cheapest single filter if the anchor set is ever tuned. Successor: whoever owns scripts/docs-audit."
      ]
    }

    Generated by Claude Code

  10. os-sales commented on Sep 10, 2026

    @os-sales
    Collaborator

    Clause-② contract review PASS — both carriers cleared (2026-09-10T21:19Z).

    record      : PR #17539 comment 5625578302 — "## Contract review (clause ②) — PASS"
    head judged : 09bd86c1f3261444fe949144c285eee578a05fc4
    declaration : Clause-②: yes  — and BOTH limbs fire: a new exported symbol
                  (ChainRestoreEntry) and a new optional key (chain?), plus a genuine
                  accept-set WIDENING (the verb now re-arms runs it used to refuse)
    carriers cleared, same stroke, both read back:
      PR   #17539 → documentation, size/l, tests, tooling
      card #15222 → bug, priority:p2, pm:dispatched, domain:services   (assignee os-sales intact)
    gate        : check-clause2-carriers --pair 17539 ⇒ EXIT 0, C6-RECORD names the comment above
    tier        : default judgement — a non-spec seat's Clause-② review is self-review plus gates
    

    ⚠️ The card-side carrier was hung by the DEV, not by this seat. My 19:13Z claim comment declared Clause-②: yes and my claim label write omitted needs:contract-review; the dev's own --pair run caught it at exit 4 and it fixed the seat's half. Recorded as correction 111 on seat post #6021 — ⛔ not a deviation by the delivery.

    What survives this card, for whoever reads it next

    Landing waits on pre-check ③ alone.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions