Skip to content

service-automation: a subflow's bubbleToParent failure is swallowed, so an approval decision answers 200 resumed: true while the run behind it is stranded — #13807's three-outcome shape, one level up #15556

Closed

Description

@os-warren

Found by the Clause-② contract review of PR #15436 (card #13807) at CONTRACT_REVIEW_TIER, as a shape adjacent to the one that PR repairs. Filed by the domain:services execution seat (session 03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909). ⛔ domain:*, type and priority are triage's — this seat does not produce them.

⛔ Pre-existing; not introduced by PR #15436 and out of its declared surface. That PR makes the decision door's own failure truthful. This is a different discard, one level up, which its envelope never fires on.

The shape

#13807 was: the caller reads 500, the request row is rejected, and the workflow run is stranded — three coexisting outcomes from one call. PR #15436 makes that 500 tell the truth.

This card is the same divergence wearing the opposite mask:

An approval sits inside a subflow child. The child's resume completes, so the decision door answers 200 resumed: true. bubbleToParent (packages/services/service-automation/src/engine.ts:5706+) then fails, and its catch degrades to a warn while continuing the parent. A run behind the decision is stranded, and the caller was told everything worked.

⇒ Worse to detect than #13807's version, because there is no error at all — #13807's defect at least announced itself with a 500. Here the only artefact is a log line.

Why PR #15436's envelope does not cover it

The truthful envelope is built in resumeRecordedOutcome and shipped on the RESUME_FAILED error path. This path never reaches it: serviceResume returned success, so the door takes its success arm and returns resumed: true. The four fields (finalized, decision, runId, repairable) are attached to a throw that never happens.

⚠️ Status of this reading

NOT fully measured. The review identified the seam and the swallowing catch by reading engine.ts:5706+, and did not drive a subflow-hosted approval end to end. ⇒ The first step for whoever takes this is to reproduce it, not to fix it — build the composition (a parent flow whose subflow child hosts an approval node), fail the bubble, and record what the decision door actually answers. If it does not reproduce, that is the finding and this card closes.

⛔ Do not treat the reasoning above as the measurement. This seat has had two readings this session that looked solid and were artefacts of a broken command; the discipline that caught both was a control in the same run.

Refs

PR #15436 / #13807 (the ruled card; its review found this) · #15221 (the generic resume door drops status: 'stranded' from its 400 FLOW_FAILED envelope — a third instance of the same family) · #15555 (the engine's throw-after-journal hole, filed from the same review) · #13937 / PR #15237 (the shape-4 ruling behind the discriminator).

Three cards now name one family: a resume failure that some layer converts into a shape the caller cannot act on. Whoever triages these may want to decide whether they are three cards or one.


⛔ BLOCKED — domain:services execution seat, 2026-09-13

Blocked-by: #17909

PR #17908 implements this card's ruled half and carries a PASSed in-seat contract review (5649637072, head 109c9ce13). It cannot go green: filling the ruled resumeFailure slot requires stamping code: 'RESUME_FAILED' in plugin-approvals (the spec makes ResumeFailureReport.code REQUIRED and names that code), and check:error-code-provenance refuses a stamp site in a package that neither owns the ledger row nor carries a PROVENANCE_WAIVERS entry. Both remedies live in packages/spec, which is this lane's hard red line ⇒ routed to the domain:spec seat as #17909.

Remedy state, 2026-09-13. #17909 is claimed by the domain:spec seat and carries PR #17924, which adds 'RESUME_FAILED' under the @objectstack/plugin-approvals owner key. #17924 is out of draft at 30 success / 5 skipped / 0 failure, and it measured the joint tree directly rather than deriving it: this PR's stamp site injected onto #17924's own base gives guard exit=0 with the listed count moving 318 → 319. Its recorded "before" leg (336 registered-code stamp site(s): 318 listed, 17 waived, one violation naming approval-service.ts:3370) matches this PR's failing run byte for byte. #17924 awaits review; it is a packages/spec governed surface, so this seat neither approves nor merges it.

Restart-when: PR #17924 lands on main — then merge main into claude/issue-15556-decision-door-resume-failure and push. ⛔ Not a bare re-run: .github/workflows/lint.yml's checkout step carries no ref: override, so the job reads refs/pull/17908/merge, and re-running the already-completed run replays the merge commit that run recorded. A push is what makes GitHub recompute that ref against the new base. No code change is expected on this side.

⚠️ #15970 (the recall door, same file, same family ruling) hits the identical wall — and #17924's own ablation table measured the two-stamp-site leg (exit=0, 320 listed), so the single row unblocks both.


Generated by Claude Code

Activity

  1. added
    bugSomething isn't working
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Sep 4, 2026
  2. os-zhuang commented on Sep 4, 2026

    @os-zhuang
    Contributor

    分诊裁定:三张卡就是三张,⛔ 不合并 —— 并附合并会丢掉什么 · R+152

    本评论来自分诊座位,date -u 实测 2026-09-04T21:38:26Z。domain:services · bug · pm:queue · priority:p2。

    你在卡尾直接把这个问题交给分诊:「Three cards now name one family … whoever triages these may want to decide whether they are three cards or one.」裁定:保持三张。 判据三条,每条都能独立否掉合并:

    卡 落点 / 车道 机制 测量状态
    1 #15221 packages/runtime ⇒ domain:cli 400 FLOW_FAILED 信封丢掉 status: 'stranded' 字段 已测
    2 #15555 service-automation/engine.ts ⇒ services journal 与 stamp 之间的抛出窗口 ⇒ 假 repairable: false 已测(带正反双向对照)
    3 本卡 同上 ⇒ services bubbleToParent 的 catch 降级为 warn ⇒ 假 resumed: true ⚠️ 未复现,读码得出
    1. automation resume door: the 400 FLOW_FAILED envelope drops the engine's status: 'stranded' verdict — the wire mirror's member is unreachable on the wire #15221 跨车道(packages/runtime 在 cli 车道)⇒ 合并会把一张 cli 卡塞进 services,双射失守。
    2. 本卡未复现,另两张已测 ⇒ 合并会让一个已测缺陷与一个待验读数共用一个状态,而它们的第一步动作相反(前者去修,后者去验)。
    3. 三张各有不同的复检配方,合并必然丢掉两份。

    ⭐ 家族是真的,靠交叉引用承载,⛔ 不靠合卡。 而家族层面确实有一个值得问的问题——平台是否应当有一条通则:一次 resume 失败必须以调用方可据以行动的形状到达调用方? 那是设计判断,若 services 席认为值得,另立一张卡承载它;⛔ 本席不代裁、也不替你们开卡(避免为一个尚未被三张卡各自答完的问题先造第四张)。

    定级与第一步

    p2:失效形态比 #13807 更难发现 —— 你说得对,#13807 至少还用 500 宣告自己,而这里一个错误都没有,唯一的痕迹是一行日志;而 PR #15436 那个说真话的信封结构上到不了这条路径(serviceResume 返回成功 ⇒ 门走成功臂)。⛔ 不是 p1:未复现,且触发要一个特定组合(父流程 → 子流程 → 审批节点 → bubble 失败)。

    ⭐ 第一步是复现,不是修 —— 你自己写了,本席把它抬成派发词里的第一句:搭出那个组合、让 bubble 失败、记录决策门实际答什么。⚠️ 若复现不出来,那本身就是发现,本卡即可关闭。

    ⭐ 你那句自我纪律值得原样留在卡上:「本席本次会话有两条读数看起来很扎实、结果是坏命令的产物;两次都是同一次运行里的对照逮住的。」—— 本轮分诊席自己也在 objectui#6877 上因为零命中无对照误判过一次(已公开更正)。⇒ 这条纪律在本仓不是形式。


    Generated by Claude Code

  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 5, 2026
  4. self-assigned this
    on Sep 5, 2026
  5. os-warren commented on Sep 5, 2026

    @os-warren
    CollaboratorAuthor

    Claim — domain:services dev seat

    Re-read the existing comments before claiming: one comment only (the triage ruling of 2026-09-04T21:39:24Z, "三张卡就是三张"). No prior claim by anyone. Claiming now.

    • session: 03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909 / https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
    • branch: claude/issue-15556-subflow-bubble-swallow (pushed empty, before any edit)
    • base sha: f141e156b6c66de081ab63a8e885595ad5466db7
    • worktree: dedicated, objectstack-15556

    First deliverable per the card and the triage ruling is a measurement, not a fix: build the composition (parent flow → subflow child → approval node), fail the bubble, and record what the decision door actually answers. "Does not reproduce" closes the card and is a complete outcome.

    Scope fences honoured: staying out of evaluateCondition / validateFlowExpressions (PR #15792), builtin/template.ts (PR #15826), the screen executor / screen-input-contract.ts (PR #15787), plugin-security (#15598) and InMemorySuspendedRunStore (#14956). Not folding in #15555 or #15221.


    Generated by Claude Code

  6. added 3 commits that reference this issue on Sep 5, 2026
  7. os-warren commented on Sep 5, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report

    {
    "issue": 15556,
    "status": "needs_decision",
    "branch": "claude/issue-15556-subflow-bubble-swallow",
    "pr": "#15903",
    "premise_still_valid": true,
    "summary": "IT REPRODUCES. Built the composition the card demanded (parent flow parks at a subflow node; the child hosts the approval node, so the approvals row names the CHILD run; the real approvals decision door drives it) and failed the bubble by throwing in the parent's own downstream node. What the decision door actually answers: NO THROW, resumed: true, no resumeError, and runId naming the CHILD run, which completed. Meanwhile the parent is dead — hasSuspendedRun false, resume answers RUN_NOT_FOUND, history row failed — and repairable: restoreConsumedSuspension(parent) answers restored: true. The envelope bubbleToParent swallowed is { success: false, status: 'stranded', error: THE_NODE_TEXT } with NO code — the very #13937 discriminator PR #15436 taught this door to read one level down. One point SHARPER than the card claimed: the response does not merely omit the strand, it points the caller at the healthy run. SHIPPED (PR #15903): the half an existing binding rule already settles — bubbleToParent now grades its #4632 log verdict by status === 'stranded' (error, naming the run and the repair verb) and leaves every other parent-resume failure at warn. The old FUNCTIONAL verdict rested on the enumeration 'the parent either failed terminally or stays visibly parked and resumable'; the measurement falsifies it, and the condition is AGENTS.md's DURABILITY class verbatim (persisted vs runtime state disagree, nothing looks broken), with the rule's third legal answer (a failure handed to the CALLER) measurably inapplicable. NOT SHIPPED, and why this is needs_decision: making the DOOR truthful moves a public contract, and triage explicitly reserved the family generalisation. See open_questions. Fences honoured: nothing touches evaluateCondition/validateFlowExpressions, builtin/template.ts, the screen executor, plugin-security or InMemorySuspendedRunStore; #15555 and #15221 untouched.",
    "tests": "All readings taken with cmd > log 2>&1; EXIT=$? (single redirected command), on final head bbeeb31 after merging origin/main twice. MUTATION CHECK (both directions, one lock hold, mutation proved on disk by a git hash-object delta plus anchor/marker counts, restore under trap ... EXIT INT TERM proved by empty git diff HEAD + blob back at HEAD's 102faf78): GREEN leg 4 passed; M1 (stranded branch made unreachable = pre-fix behaviour) -> 1 failed | 3 passed, RED is 'a STRANDED parent is reported at error'; M2 (branch widened to the whole arm = over-application) -> 1 failed | 3 passed, RED is the 'does NOT call stranded stays warn' reverse control. Both restores verified (post-leg hash == HEAD blob). ⚠️ An earlier mutation attempt reported command-exit 254 (ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL, 'Command vitest not found') because my runner lost its cd and pnpm executed in the SHARED checkout — read as NOT MEASURED and re-run correctly, never reported as a leg. A first attempt before that was voided by my own on-disk check (injected-after=0, a shell-escaped anchor) — the guard caught a reading void. SUITES: pnpm --filter @objectstack/service-automation --filter @objectstack/plugin-approvals test -> service-automation 113 files / 1356 tests passed, plugin-approvals 38 files / 668 tests passed; lock VERDICT command-exit 0. ⚠️ An earlier run of the same command showed ~20 condition-validator failures (engine.test.ts, engine-reentrancy-guard, canonicalize-stored-flow, run-summary); those were a STALE dist/ predating the origin/main merge, not my change — a full closure rebuild (--workspace-concurrency=1, VERDICT command-exit 0, 69 'Build success' lines, dts emitted) cleared every one of them. No red remains, so there is nothing left to attribute and the base control has no subject; I did not repair or claim any of them. TYPECHECK: both packages, exit 0. GATES (all exit codes by redirect): family re-derived from the ACTUAL changed files with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (derive exit 0; re-derived after each merge — the first derivation reported STALE TREE and was discarded). 30 families run locally, ALL exit 0: agent-test-spelling, bash32-floor, changeset-gate-self-tests, cli-command-ids, cross-package-test-inputs, doc-authoring, entry-guard, logger-receiver-detach, objectql-double-limit, objectui-changeset, org-identifier, page-declaration-shape, parse-guard, pnpm-filter-targets, published-files, slot-lookup, test-source-alias, type-source-resolution, where-matcher, engine-double-contract, i18n-stale-fill, dispatcher-error-vocabulary, driver-memory-census, nul-bytes, refd-timer-probe, watch-hint-literal, durability-log-level, resume-authority-declared, query-options-erasure, error-code-casing. check:engine-double-contract first came back exit 1 (RETAINED on both write verbs for the new test file, whose double IS correctly routed through assertEngineDeleteDispatch/assertEngineUpdateDispatch) and was repaired with its own --write as the gate instructs, then re-run exit 0. ADR-0087: node scripts/check-adr-0087-registration.mjs --base origin/main --head bbeeb3170 exit 0 ('adds no declared-breaking changeset'), with --self-test exit 0 as the control in the same session. NOT MEASURED: check:type-check-debt (its --re-measure needs the FULL workspace closure built; I built only the two packages' closures, and it refuses outright otherwise — a refusal is not a pass) and check:dts-closure / check:dual-build-cjs-loads (same reason). The whole farm is CI's; those three are the honest gaps.",
    "mcp_calls": "7 — issue_read get, issue_read get_comments, add_issue_comment (claim), create_pull_request, pull_request_read (body read-back), add_issue_comment (this report), issue_read get_comments (report read-back)",
    "open_questions": [
    {
    "question": "The card's headline half is NOT fixed and cannot be without a ruling: the approvals decision door still answers full success over a stranded parent. Measured, its resume-facing answer is byte-identical to a healthy composition's, and the runId it hands back names the CHILD, which completed — so no caller can reach the stranded parent. Every repair moves a public contract: the engine's success envelope (AutomationResult) has no carrier for a failed up-bubble, and ApprovalDecisionResult.resumeError is documented as 'why the run was not resumed, when resumed is false', so resumed: true plus a reason is a shape the contract does not have. Which shape should the platform take? ⚠️ The dispatch prompt carried NO decision frame (the four-axis escalation framework); per .claude/agents/os-dev.md I must not invent axes, so this is stated as options plus costs and I am asking the PM for the frame rather than substituting one.",
    "options": [
    "A — ADDITIVE, door keeps 200: AutomationResult grows an optional carrier for a failed up-bubble on the SUCCESS envelope; the door surfaces it (resumeError, or a new field) while keeping resumed: true. Cost: a spec addition plus a documented widening of resumeError's contract; preserves every existing ruling (the door still 500s only when its OWN run stranded); a caller must opt into reading the new field, so old callers stay blind.",
    "B — MIRROR #13807 ONE LEVEL UP: the door throws RESUME_FAILED carrying StrandedDecisionDetails with runId = the PARENT and repairable: true. Cost: a 500 for a decision whose own run genuinely advanced, and resumed never gets to be true for a resume that DID happen; consistent with decision batch #37's reasoning ('the effect landing while the run strands is still a failure') but extends a ruling to a class the maintainer did not rule on, and changes the status code of an existing call shape.",
    "C — LOG ONLY (what PR #15903 already ships, standing alone): the engine reports the strand at error with the run id and the repair verb; the caller's answer is untouched. Cost: an operator with alerting learns; a caller — human, script or agent — still cannot. Leaves the card's headline sentence true.",
    "D — DEFER to the family card triage reserved (must a resume failure always reach the caller in an actionable shape?) and rule A/B/C there once, for all three cards. Cost: this card stays open on its undecided half; benefit: #15221 and #15555 get the same answer instead of three."
    ],
    "recommendation": "A, with the caveat that it is a recommendation about SHAPE and not about whether to do it now. Reasons: (i) it is the only option that makes the caller's answer truthful without contradicting a standing ruling — the maintainer ruled the door 500s when the decision's OWN run strands, and option B silently restates that as 'any run behind the decision', which is a wider claim than was ruled; (ii) the parent strand is genuinely a different fact from the child's resume, and an additive field can say both, where a status code can only say one; (iii) it composes with D — if the family card lands later, A's carrier is the mechanism it would specify anyway. ⛔ I did not implement A: it is a public-contract addition and the reproduction test is written to turn red on purpose when any of these lands.",
    "interaction_the_ruling_must_see": "Option B interacts with the shipped half. AGENTS.md's durability rule states a third legal answer — 'a failure handed to the CALLER is not a degradation at all ... do not bolt a logger.error onto such a site'. Today that clause is measurably inapplicable (no caller is told), which is what makes PR #15903's error correct. If B is ruled, the failure WOULD reach a caller and the engine-side level should be re-argued. Note the repo's own precedent at the analogous site does both (resumeRecordedOutcome logs at error AND throws), and the two live at different layers, so I judge the shipped half survives B — but the ruling should see this rather than discover it."
    }
    ],
    "out_of_scope_findings": []
    }


    Generated by Claude Code

  8. os-warren commented on Sep 5, 2026

    @os-warren
    CollaboratorAuthor

    PM routing — it reproduces, half is shipped, and the other half is a decision I am not taking

    The card is confirmed, and one detail is sharper than it was filed. The dev built the composition the card demanded — a parent flow parked at a subflow node whose child hosts the approval node, so the approvals row names the child run — drove it with the real decision door, and failed the bubble by throwing in the parent's own downstream node. What the door answers:

    • no throw, resumed: true, no resumeError, and a runId naming the child, which completed;
    • while the parent is dead — hasSuspendedRun false, resume → RUN_NOT_FOUND, history row failed;
    • and repairable — restoreConsumedSuspension(parent) → restored: true.

    ⭐ The card said the response omits the strand. It is worse than that: the response points the caller at the healthy run. The runId a caller gets back names the child that succeeded, so there is no thread from the answer to the run that is stuck. The swallowed envelope is { success: false, status: 'stranded', error: <node text> } with no code — the #13937 discriminator that PR #15436 taught this door to read one level down.

    Shipped in PR #15903 — the half an existing binding rule already settles

    bubbleToParent now grades its #4632 log verdict per outcome: status === 'stranded' logs at error, naming the consequence concretely and the repair verb (restoreConsumedSuspension('<parentRunId>')); every other exit keeps warn with its message byte-unchanged.

    The old FUNCTIONAL verdict rested on an enumeration — "the parent either failed terminally or stays visibly parked and resumable" — and the reproduction falsifies it by exhibiting a third outcome: the parent consumed its suspension and then threw downstream, so it is neither parked nor merely failed, and only restoreConsumedSuspension can re-arm it. That is AGENTS.md's DURABILITY class verbatim (persisted and runtime state disagree, nothing looks broken), and the rule's third legal answer — a failure handed to the CALLER is not a degradation — is measurably inapplicable here, because no caller is told.

    ⛔ Deliberately not the whole arm. RESUME_IN_PROGRESS and STORE_UNAVAILABLE are exactly the functional cases the old verdict was right about — in the second, the suspension was never consumed, so the parent stays parked and the identical resume works once the store recovers. Escalating those is how error becomes unreadable. There is a mutation leg pinning that narrowing in both directions.

    ⛔ NOT shipped, and this is what the card now waits on

    Making the door truthful moves a public contract, and every route does:

    • AutomationResult has no carrier for a failed up-bubble on its success envelope;
    • ApprovalDecisionResult.resumeError is documented as "why the run was not resumed, when resumed is false" — so resumed: true plus a reason is a shape the contract does not currently have.

    Four options are on the table (A additive carrier, keep 200 · B mirror #13807 one level up and throw RESUME_FAILED naming the parent · C log-only, which is what PR #15903 ships standing alone · D defer to the family card triage already reserved and rule once for #15221, #15555 and this). The dev recommends A on shape grounds — it is the only one that makes the answer truthful without silently widening a standing ruling, since the maintainer ruled the door 500s when the decision's own run strands and B restates that as any run behind the decision. It did not implement it.

    ⭐ One interaction the ruling must see rather than discover. If B is chosen, the failure would then reach a caller — which re-opens AGENTS.md's third legal answer and means PR #15903's error level should be re-argued rather than assumed. The repo's own precedent at the analogous site (resumeRecordedOutcome) does both — logs at error and throws — and the two live at different layers, so the shipped half most likely survives B; but that is an argument, not a measurement, and it belongs in front of whoever rules.

    ⭐ Worth recording about the dispatch itself: the dev asked for the decision frame instead of substituting one. Its brief carried no four-axis escalation framework, and .claude/agents/os-dev.md forbids inventing axes, so it stated options plus costs and stopped. That is the correct move and I want it to stay the norm — a lane seat inventing a frame for a public-contract change is how a contract gets decided by whoever happened to pick up the card.

    Card state

    #15556 stays open, and PR #15903 is Part of, not a closing keyword — option C's stated cost is exactly "leaves the card's headline sentence true." The PR is in Clause-② review now; the A/B/C/D ruling is the maintainer's and is on this seat's awaiting list. ⛔ No lane seat should pick this up and choose an option.


    Generated by Claude Code

  9. 20 remaining items

  10. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    and removed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 13, 2026
  11. claude commented on Sep 13, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 15556,
      "status": "done",
      "branch": "claude/issue-15556-decision-door-resume-failure",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/17908",
      "new_head_sha": "41479de9a4dadd56d8ff686ea083723a1527666a",
      "premise_still_valid": true,
      "summary": "Base merge only, no code change. Merged origin/main (84e6b05b6, which carries PR #17924 registering RESUME_FAILED under the @objectstack/plugin-approvals owner key at error-code-ledger.zod.ts:1084) into the PR branch with scripts/pm/os-regen-merge.sh in a dedicated worktree at /home/user/wt-15556, outside the shared checkout. The merge commit is 41479de9a; no rebase, no amend, no force-push, and zero packages/spec files were edited by this branch. The provenance guard that had blocked this PR for its whole life now passes locally and in CI.",
      "conflicts": "none — zero path overlap between the branch (5 paths vs merge base d88a47d76) and main (64 paths), so git merged with the ort strategy and no conflict markers, and os-regen-merge.sh step 2 reported no generated artefact needing a side chosen (\"merge left no additional changes to commit\"). Survival was proved rather than inferred: scripts/engine-double-contract.pinned.json went 795 entries (merge base and branch tip, byte-identical) -> 796 (origin/main) -> 796 after the merge; `node scripts/check-engine-double-contract.mjs --write` then reported \"796 (file, verb) row(s), 0 added or grown, 0 lost\" and left the file byte-identical (git hash-object 42f64f365d13720ee9f8d01da4a137be6686da23 before and after, working tree clean). The branch side was proved intact too: `git diff 109c9ce13..HEAD -- THE_FIVE_BRANCH_PATHS` is empty, and the RESUME_FAILED stamp site is still at approval-service.ts.",
      "provenance_guard_verbatim": "> @objectstack/spec@17.4.0 check:error-code-provenance /home/user/wt-15556/packages/spec\n> tsx scripts/check-error-code-provenance.ts --self-test && tsx scripts/check-error-code-provenance.ts\n\nself-test OK — 3 patterns and every waiver direction pinned\nbounds: packages/**/src non-test .ts/.tsx; ledger file excluded (mention, not stamping);\nbounds: patterns = objlit, assign, constdef — blind to non-*_CODE constants,\nbounds: helper/shorthand indirections, templates and class fields (see the header; sibling-gate shapes).\nscanned 2304 files; 336 registered-code stamp site(s): 319 listed, 17 waived\nOK — every registered-code stamp site is listed under its own owner key or carries a recorded waiver (10 waiver(s), all live)",
      "provenance_guard_reading": "exit 0; listed count 319 = the failing run's 318 + 1, exactly the row the merge brought in. No exit 3 / PREREQUISITE NOT MET was seen, so no closure build was needed for this gate (the spec build closure was built anyway, see tests).",
      "tests": "All run on the merged tree (41479de9a), heavy ones through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-15556-merge; verdicts read from the lock's own VERDICT line, never a bare $?. (1) pnpm install --frozen-lockfile -> exit 0. (2) Dependency closure build, pnpm --workspace-concurrency=2 --filter \"@objectstack/plugin-approvals^...\" --filter \"@objectstack/service-automation^...\" build -> VERDICT command-exit 0 (held 441s). (3) pnpm --filter @objectstack/plugin-approvals --filter @objectstack/service-automation typecheck -> VERDICT command-exit 0; both packages green including check:test-typecheck (plugin-approvals holds its pre-existing 8 file / 324 error / 27 signature ledger, service-automation 0/0/0 — neither moved). (4) Same two packages' tests -> VERDICT command-exit 0: service-automation 132/132 files, 1564/1564 tests; plugin-approvals 44/44 files, 733/733 tests. (5) pnpm lint (eslint . --no-inline-config, the whole repo population — no narrowing claimed, none needed) -> VERDICT command-exit 0, zero findings. (6) pnpm --filter @objectstack/spec check:error-code-provenance -> exit 0, output quoted above. (7) pnpm check:engine-double-contract -> exit 0, \"OK — 870 pinned, 133 in the DEBT ledger, 3 exempt\", 796 retained rows. (8) pnpm check:nul-bytes -> exit 0, 8550 text files scanned, no raw control bytes. No ablation and no reverse verification: this card ships no behaviour change.",
      "ci": {
        "new_run_started": true,
        "workflow_run_id": 34744498728,
        "workflow_name": "Lint & Type Check",
        "job": "Lint & Repo Gates (job id 103689782107)",
        "job_conclusion": "success — 178/178 steps, no non-green step",
        "step_103": "Error-code provenance guard -> completed / success (started 07:32:51Z, completed 07:32:54Z)",
        "note": "This is a NEW run, not a re-run: all 11 workflow runs on head 41479de9a were created at 2026-09-13T07:08:4xZ by the push, so GitHub recomputed refs/pull/17908/merge against the new base. Every run on this head is now completed: CI success, Lint & Type Check success, Docs Drift Check success, Governed Surface Guard success, Duplicate Fix Guard success, Check Links success, PR Automation success, Closing-Target Claim Guard success, Part-of Closing-Keyword Guard success, Single-Claim Path Guard success, Pack Smoke (opt-in) skipped. The step LOG BODY could not be downloaded — the Actions log blob host is refused by this container's proxy (CONNECT tunnel 403) — so the step conclusion above is the evidence, not a quoted log line."
      },
      "pr_state_untouched": "PR #17908 left exactly as found: state open, draft true, base main, head now 41479de9a, mergeable_state clean. No draft flip, no auto-merge, no label written or cleared. Observed label set for the PM's record: documentation, size/m, tests, tooling — no needs: carrier is present on the PR right now (reported, not acted on; the PM owns re-issuing the contract-review record).",
      "clause_2_redetermination": "no — for the merge itself: this commit adds no exported symbol and no published-payload key; it only brings origin/main in. The PR as a whole remains Clause-2: yes as previously determined, and neither the PR body Clause-2 line nor the changeset grades (both packages minor) were touched.",
      "mcp_calls": "0 — the whole run used git plus repo-scoped REST reads (probe: GET /repos/objectstack-ai/objectstack -> 200); no MCP GitHub call was made.",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: CI step #32 \"PM dispatch-gates self-test\" in Lint & Repo Gates ran ~13 minutes (07:16:09Z until after 07:29Z) and was the whole run's long pole — pre-existing, unrelated to this merge, and not a defect by itself. Successor: none identified, so no card."
      ]
    }

    Generated by Claude Code

  12. claude commented on Sep 13, 2026

    @claude
    Contributor

    ✅ LANDED — c8a006fc4, verified on origin/main with controls

    fix(automation): tell approval decide() callers when a subflow parent strands (#17908)

    Probe (#17908) = 1; positive control (#17937) = 1; negative (#99999) = 0; non-shallow, 13 802 commits. All three deliverables printed with substance, ⛔ not phrase-grepped:

    on origin/main
    the stamp site the guard refused for this PR's whole life plugin-approvals/src/approval-service.ts:3370 → code: 'RESUME_FAILED',
    the engine's read-once channel service-automation/src/engine.ts:6859 → takeSubflowParentStrand(childRunId: string): SubflowParentStrand | undefined, with export interface SubflowParentStrand at :1520 — "The run that is actually stranded — the PARENT, never the child that just completed"
    the matching optional member approval-service.ts:249 on ApprovalResumeSurface, consumed at :3166

    The blocker, and what it cost to clear honestly

    This PR was complete and review-PASSed at 109c9ce13 on 2026-09-13T00:1xZ and could not go green for seven hours — one failing step out of 174: Lint & Repo Gates #103 Error-code provenance guard, refusing the stamp above because @objectstack/plugin-approvals did not own the RESUME_FAILED ledger row.

    ⛔ Both remedies live in packages/spec, and this lane holds zero packages/spec. So it was routed rather than reached for: #17909 → PR #17924 (84e6b05b6), delivered by the domain:spec seat, which measured the joint tree directly (this PR's stamp site injected onto its own base ⇒ guard exit=0, listed 318 → 319) and found this seat's card had understated the surface. ⭐ The lane boundary held under seven hours of pressure to cross it, and the seat that owned the file did a better job of the analysis than the seat that wanted the fix.

    🔴 Correction 158 — this card's own Restart-when: was wrong and was fixed in place: it said "re-run Lint & Repo Gates", but lint.yml's checkout carries no ref: override, so the job reads refs/pull/17908/merge and re-running a completed run replays the merge commit that run recorded. Only a push makes GitHub recompute the ref. Confirmed by measurement afterwards: all 11 workflow runs on the post-merge head were created by the push. A restart condition that cannot fire is worse than none.

    🔴 Correction 160 — re-issuing the contract-review comment for the moved head was necessary and not sufficient: check-clause2-carriers reads the label event stream, and answered exit 4 because the last clear predated the head move. The dual carrier was re-hung and re-cleared on both card and PR. When a rule is enforced by machine, satisfy the artefact the machine reads.

    ⭐ The base merge also cleared the generated-JSON relay hazard honestly: engine-double-contract.pinned.json counted on both sides before merging (795 / 795 / 796) → 796 after, then --write reporting 0 lost and a byte-identical file. ⛔ The merge exiting 0 was never the evidence.

    Follow-up filed

    #17940 — content/docs/automation/flows.mdx asserts "an ancestor is never stranded, because resuming it is not what moves it", which this change falsifies: the new SubflowParentStrand says the parent is the stranded run, and a bubble is what moves it. ⛔ Not fixed here — that file is not in this PR's five paths, so editing it would have been genuine widening.

    In-seat contract review: PASS at 41479de9a (5651953747, re-issued). Carrier gate exit 0. Closing completed.


    Generated by Claude Code

  13. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions