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
service-automation: the packaged-subflow disable refusal tells the admin to disable the calling flow first, but a disabled caller still blocks the disable — the prescribed remedy can never complete #20678
Recorded while grounding as docs/qa/platform-checklist/FOLLOW-UPS.md §8a D19 (2026-08-26); no card existed. Now measured live.
What happens
Disabling a packaged flow that another packaged flow calls as a subflow is refused with 409 DELETE_RESTRICTED and the remedy "Disable the calling flow first, or leave this one armed." Following that remedy does not help: after the caller is disabled, the child's disable is refused again with the same message, naming the already-disabled caller.
Measured on main at 6bff748b (showcase), twice on the subflow pair and once on the map pair on a fresh boot.
Reproduction
As the seeded admin: POST /api/v1/automation/showcase_notify_owner/toggle {"enabled":false} → 409 DELETE_RESTRICTED "Flow 'showcase_notify_owner' cannot be disabled while 1 packaged flow still calls it as a subflow: 'showcase_task_done_notify_owner'. … Disable the calling flow first, or leave this one armed."
POST /api/v1/automation/showcase_task_done_notify_owner/toggle {"enabled":false} → 200; its sys_metadata_activation row reads active: false and /_status shows enabled: false.
Retry step 1. Expected: 200 and a ledger row active: false for showcase_notify_owner. Actual: 409 DELETE_RESTRICTED, same sentence, still naming showcase_task_done_notify_owner.
Map pair, same result: disable showcase_release_signoff (200), then showcase_one_task_signoff is still refused naming it.
Mechanism (read from source at 6bff748b)
packagedSubflowCallers in packages/services/service-automation/src/engine.ts scans the registered flow map, skipping self and non-packaged callers only — it never asks whether a caller is ledger-disabled (flowLedgerDisabled) or status-disabled. toggleFlow throws from that list before any durable write. ADR-0126 §7.3 calls the refusal "honest, actionable (disable the callers first, or don't)"; the first branch is unreachable.
Expected
Either a disabled caller no longer guards its callee (so the prescribed sequence completes), or the refusal stops prescribing a sequence the door will refuse. The engine-level pins in flow-activation-ledger.test.ts do not cover the sequence.
PR #20711 (Part of #20678) merged through the merge queue as 679f95ec on origin/main.
Review: the contract review PASS on the landed head is 5898430659.
Content check: the landed commit's git patch-id --stable equals that of the reviewed head 08ed6d7b against its base.
What now holds: re-enabling a packaged flow that the ledger holds off is refused with RESOURCE_CONFLICT / 409 while a packaged subflow it calls is disabled. The refusal names a remedy the subflow's real state admits.
Release note:@objectstack/service-automation ships this as a minor marked BREAKING (Clause-②: no (narrowing)).
Carried into the disable-half stage, from that review:
The cycle corner (③.4). A subflow disabled BOTH ways inside a ledger-disabled cycle is skipped whole: disabledPackagedSubflows hits the cycle continue before it reads the status. So the completable publish remedy is not named, and the caller is armed onto a status-disabled child. The cycle exemption answers only the ledger bits, so it should never exempt a status-disabled child. This is a corner of this card's own guard, taken as a rider with a pin.
The registerFlow door (③.3). A package upgrade, a publish or a hot reload can still arm a packaged caller onto a disabled child. The reach through a public door is not measured, so the stage measures it and reports it. It is not built unless measured and scoped.
ADR-0126 §7.3's heading, "attached to disable" (③.5). It now under-describes the guard, which also runs on enable. It is extended, not reversed, and triage's answer 5898655174 amends nothing in §7.3.
An addendum sentence would be ADR text, a governed surface in the maintainer's hand. So it is ⛔ not in any code PR.
This seat raises it with the maintainer in the round report instead.
Release: session session_01XY5uCwTjZj7884yYtyur4H. The enable half has landed, and triage's answer 5898655174 (A) is in hand, so the card returns to pm:queue. This seat re-claims it at once for the disable-half stage, on 679f95ec.
Claim: PM loop round 1 · stage 2 (the disable half, triage's answer A) · 2026-09-29T21:08Z
Session: session_01XY5uCwTjZj7884yYtyur4H
Account: os-justin (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-20678-subflow-disable-parked-run
Worktree: objectstack-issue-20678-s2
Domain: domain:services
Seat: domain:services#6021
File surface: packages/services/service-automation/src/engine.ts (packagedSubflowCallers, toggleFlow's disable guard, disabledPackagedSubflows for the cycle rider), packages/services/service-automation/src/flow-activation-ledger.test.ts, .changeset/20678-subflow-disable-parked-run.md (stop on breach; explain in the report)
Container & model: M, mode:subagent, model: opus (dispatch-gates --tier at 679f95ec: no path-derived mandate; floor sonnet · default opus · ceiling fable)
Clause-②: yes (widening). Taken as the seat's reading, to be confirmed by measurement: a disable refused today (a packaged callee whose only packaged callers are disabled and hold no parked run) becomes accepted, and nothing accepted today becomes refused. The dev declares what the delivered diff measures, and the at-tier contract review is owed before enqueue.
Thread-read: 5898894424
Serial constraints cleared: PR #20711 (the enable half, same functions) merged as 679f95ec, and this stage bases on it. Open PR #20695 touches only builtin/decision-overlapping-edge-conditions.pin.test.ts in this package, a file this stage does not touch (census of all 11 open PRs at this claim). #20596's service-automation citation stage stays serial behind this one. The seat's other in-flight item, #20596 stage 7 (plugin-approvals, in review), is disjoint.
{
"issue": 20678,
"status": "done",
"branch": "claude/issue-20678-subflow-disable-parked-run",
"pr": "https://github.com/objectstack-ai/objectstack/pull/20724",
"session": "session_01XY5uCwTjZj7884yYtyur4H",
"premise_still_valid": true,
"summary": "The disable half of the ADR-0126 §7.3 guard is built as triage's answer A reads it. In toggleFlow(name, false), a packaged caller guards when it is enabled (step: disable it first). It also guards when it is disabled, by the ledger or by its status alike, and holds a parked run (step: cancel each named run through the ADR-0044 door, POST /automation/CALLER/runs/:runId/cancel, or let it finish). A disabled caller with no parked run no longer guards. DELETE_RESTRICTED / 409 and subflowCallers are unchanged, and resume()/resumeInternal() are untouched. One helper, parkedRunsOf, answers both the verdict and the refusal's run list. It reads through the one reader of both run stores, readSuspendedRuns: the body listSuspendedRunsDurable already served, moved unchanged. The listing keeps its degrade posture, and the guard throws on an unlistable store, so an outage never reads as 'no parked run'. The B6 cycle rider is in. Measured live on the showcase: the card's own reproduction now completes (409 naming the armed caller, 200, 200). The map pair with a parked sign-off answered 409 naming the run and the door; the cancel answered 200 cancelled:true, and then the disable answered 200. Clause-② measures 'yes (narrowing)' (not the claim's 'yes (widening)'). The B6 corner refuses an enable that was accepted.",
"tests": "All at head d59c97a50. Red first: 97222b887 against the unfixed engine gave 'Tests 7 failed | 40 passed (47)', each failure for the intended reason (the run id or the new step missing, the store error masked by the old DELETE_RESTRICTED, the B6 enable accepted). The fix is 172680217. pnpm --filter @objectstack/service-automation test: 'Test Files 155 passed (155)', 'Tests 1942 passed (1942)'. pnpm --filter @objectstack/service-automation run typecheck: exit 0 (tsc --noEmit, then 'check:test-typecheck: OK'). --listFiles counts flow-activation-ledger.test.ts once in tsconfig.json and once in tsconfig.test.json. Ablation: 7 legs via scripts/ablation-replace.mjs wrap mode (anchor hit x1, mutation proven on disk as anchor 1 to 0 and a blob change, restore proven: 'ok restored: blob == HEAD (7d48c737834c) and git diff HEAD is empty'), with an outer trap on EXIT/INT/TERM restoring by absolute path, re-run from committed d59c97a50. No dist leg: the test imports ./engine.js relatively, so it resolves src. M1 every packaged caller guards: 5 red (pins 1-5). M2 hot cache only: 2 red (durable-only, unlistable store). M3 degrade posture: 1 red (unlistable store). M4 cycle exemption asked of a status-disabled child: 1 red (B6). M5 no run ids in the refusal: 4 red (pins 2-5). M6 a disabled caller never guards: 4 red (pins 2-5). M7 'disabled' read from the ledger bit alone: 1 red (status-disabled caller). Live, a showcase boot (pnpm dev -- --fresh -p 41863, built at d59c97a50, stopped by its recorded process group): the card's reproduction and the map pair sequence, as in the summary.",
"mcp_calls": "0 — no MCP GitHub tool was called; all GitHub reads were REST GETs to api.github.com for objectstack-ai/objectstack only",
"api_writes": "3 relay dispatches (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]) carrying 3 endpoint writes: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls (draft) = PR #20724, body read back byte-identical (16179 chars); (2) label-write, POST /repos/objectstack-ai/objectstack/issues/20724/assignees [os-justin], read back as matching, no label written; (3) post-stamped, POST /repos/objectstack-ai/objectstack/issues/20678/comments (this report). Plus git push (not REST): the empty-branch probe, then 5 commit pushes.",
"gates": {
"head": "d59c97a50",
"derived": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at d59c97a50 gave 62 commands, the same 62 as pm-gates-at-dispatch.txt. All 62 ran with the exit captured before any pipe. --ran: '62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3)'. 61 exit 0. check:dual-build-cjs-loads and check:type-check-debt first exited 3 (PREREQUISITE NOT MET); after a full package build (turbo run build, 71/71 tasks, 45 cached) both exited 0 ('4 ledger entr(ies) re-measured ... none above its recorded number'). One designed red: node scripts/check-empty-changeset.mjs --base origin/main exits 1 on the DELIBERATE CORRECTION of the pending stage-1 changeset (see deviations and open_questions).",
"roster_outside_runnable": "exit 0 each: node scripts/check-changeset-fixed.mjs; pnpm check:authz-resolver; pnpm check:error-code-casing ('no unlisted lowercase error codes in 6989 scanned file(s)'); pnpm check:filter-alias-parity",
"extra": "exit 0 each: pnpm check:durability-log-level; pnpm check:startup-registry-verdict; node scripts/check-changeset-no-major.mjs --base origin/main --event (a synthetic pull_request payload carrying the PR body: 'LEVEL AXIS: this PR declares clause-② yes (narrowing), and no package whose packages/**/src/** it moves is graded patch'); check-adr-0087-registration with the same payload ('1 declared-breaking changeset(s), each carrying an ADR-0087 disposition', not-required (no-migration-prescription))",
"package": "pnpm --filter @objectstack/service-automation test exit 0; pnpm --filter @objectstack/service-automation run typecheck exit 0",
"lint": "declared narrowing: eslint --no-inline-config --format json on the 4 changed files gave 4 files, 0 errors, 2 warnings (both .md changesets: 'File ignored because no matching configuration was supplied'). Population from eslint.config.mjs files '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'. The config never enables type-aware linting (its own comment; no parserOptions.project), so the diff cannot move a verdict on an untouched file.",
"not_measured": "the 6 value-bearing CI invocations dispatch-gates marks not runnable locally (check-issue-citations --census, 3 check-shard-attestation --emit, 2 check-test-completeness); reason: their argv comes from the workflow. CI on PR #20724: not awaited (in_progress at report time).",
"nul_bytes": "pnpm check:nul-bytes exit 0; a control-byte self-scan of the 4 files found nothing"
},
"line_budget": "n/a: no skills/** or line-ratcheted ledger touched. Diff vs base 679f95ec5: +419 / -48 (467 changed lines, under the 5000 human-merge threshold).",
"files_changed": [
"packages/services/service-automation/src/engine.ts (+167/-45)",
"packages/services/service-automation/src/flow-activation-ledger.test.ts (+229/-1)",
".changeset/20678-subflow-disable-parked-run.md (+21, new)",
".changeset/20678-subflow-disable-sequence.md (+2/-2, deviation: the pending stage-1 note)"
],
"deviations": [
"Outside the declared surface: the pending stage-1 changeset .changeset/20678-subflow-disable-sequence.md is amended in place, because this diff makes two of its sentences false ('Disabling a subflow is unchanged', and the cycle bullet under 'Not refused' that the B6 corner now contradicts). Per AGENTS.md a release note is corrected in its own entry. check-empty-changeset stays red on it by design until the seat confirms on the PR (open question 1).",
"Clause-② declared 'yes (narrowing)', not the claim's 'yes (widening)'. Measured: the disable widens, and the B6 rider narrows the enable accept set in one corner (a child disabled both ways inside a ledger cycle was skipped whole, and is now named).",
"Inside engine.ts but outside the functions the claim names: listSuspendedRunsDurable is split into a thin public wrapper plus the private readSuspendedRuns(onEnumerationFailure), the one reader of both stores (B1). One paragraph of its catch comment, which claimed the method has no production consumer, is corrected (plugin-approvals reads it and fails closed).",
"The disable guard's parked-run read is awaited only when a switched-off caller is judged. A first version awaited it on every disable, and flow-terminal-messages.test.ts (it calls toggleFlow without await) went red on the one-microtask shift. Fixed in the engine (d59c97a50), not in that test.",
"A moved envelope comment re-added the citation #10243, which check-issue-citations reports as allocated-but-absent. It now cites the toggle ruling's commit 266436a7f and its in-tree record (isFlowAuthoringWrite).",
"Throwaway probe test files were written under src/ to print the refusal texts and measure B2/B7 at the engine seam. They were removed and never committed (tree verified clean before every commit).",
"A full workspace build (turbo, 71 tasks) was run for the two exit-3 gates and for the live boot. One showcase dev server ran on port 41863 and was stopped by its recorded process group (session 32651)."
],
"cleanup": "worktree objectstack-issue-20678-s2 removed (node_modules deleted, then 'git worktree remove' without --force, exit 0). Remote head == local head d59c97a50 was verified before removal. The dev server was stopped, with no listener left on 41863. The report comment was posted with the shared checkout's post-stamped.mjs (blob-identical to the worktree's copy), executed only.",
"open_questions": [
{
"question": "Confirm the deliberate correction of the pending stage-1 release note .changeset/20678-subflow-disable-sequence.md in PR #20724 (check-empty-changeset stays red until confirmed). Four axes. Business: the next release's CHANGELOG would otherwise say 'Disabling a subflow is unchanged' in the same release that changes it, and would list a both-ways cycle child as 'Not refused' while it is refused. Long-term: AGENTS.md corrects a release note in its own entry, never by an erratum in a later one. AI-proofing: an upgrading agent greps the entry it lands on, so a false sentence there misleads with no counter-signal. Startup scope: two sentences in one file, no new mechanism.",
"options": [
"A: keep the correction; the seat confirms it on PR #20724 and lands with that one non-required red",
"B: revert that one file; the false sentences ship, and this PR's own changeset carries the true statement"
],
"recommendation": "A, because it keeps the release note true where readers land (AGENTS.md), at the cost of one confirmation; B ships a known-false sentence."
},
{
"question": "Confirm the Clause-② declaration 'yes (narrowing)' with a minor + BREAKING changeset and the ADR-0087 marker not-required (no-migration-prescription). The claim line read 'yes (widening)'. The narrowing is the B6 rider, the order's in-scope corner. Four axes. Business: the corner is real (an enable accepted onto a child that stays disabled). Long-term: declaring the narrowing is what the arm exists for. AI-proofing: an undeclared narrowing is the #16421 failure. Startup scope: no extra mechanism.",
"options": [
"A: keep as delivered",
"B: drop the B6 rider to make the diff a pure widening (the corner stays open)"
],
"recommendation": "A, because the rider was ordered in scope, and it measurably narrows. check-changeset-no-major reads the declaration as delivered and exits 0."
}
],
"out_of_scope_findings": [
"class: a · reach: public door measured live: a create request through the automation create door that asserts package provenance answered 200 [request detail redacted by the domain:services seat under the security-family disclosure rule; the finding is carried abstractly by #20761]. It created a flow the engine treats as packaged (its re-enable is refused 409 RESOURCE_CONFLICT by the enable guard), calling the ledger-disabled showcase_one_task_signoff, registered enabled:true. POST /api/v1/automation/NAME/trigger then answered 400 FLOW_FAILED 'subflow showcase_one_task_signoff failed: Flow ... is disabled'. The toggle door refuses that same state. · evidence: B7, the registerFlow door; the engine seam is also measured (a new packaged caller is bound onto a ledger-disabled child, and a subflow republished obsolete under an armed packaged caller fails the caller at its node). The real producer (a package upgrade adding a caller) is not named. DELETE /api/v1/automation/NAME (unregisterFlow) of a packaged subflow is the same invariant's third door, read and not measured. §7.3 holds on toggleFlow only; one family card fits all three doors. · dedupe words: registerFlow subflow guard; packaged caller armed disabled subflow; automation create door FLOW_DISABLED; ADR-0126 7.3 door",
"class: a · reach: public door measured live: POST /api/v1/automation/NAME/toggle on a flow created without package provenance answered 400 VALIDATION_FAILED 'Package is required' (field package_id), for both enabled:false and enabled:true. The caller sent no package field; the activation row is written with an empty package id. This predates the PR and is not changed by it. · evidence: the same showcase boot, flow zz_probe_b7_customer (deleted after). · dedupe words: toggle Package is required; activation ledger customer flow toggle; sys_metadata_activation package_id",
"carrier: none · boundary: ObjectStoreSuspendedRunStore.list() reads at most 1000 paused rows, so beyond that (deployment-wide) a caller's parked run can be missing from the enumeration and the disable would be accepted. Not measured. Noted in the PR Acceptance notes, not filed.",
"carrier: none · boundary: a caller run executing (not parked) at the instant of the disable is in neither store and can still reach its subflow node. Triage ruled on \"holds a parked run\"; not built around. Noted, not filed.",
"carrier: none · boundary: a failed caller run with a restorable consumed suspension (the restore-suspension door) is terminal and does not count; restoring it after the child is disabled re-parks it into a failure. Read, not measured. Noted, not filed.",
"carrier: the seat (raised with the maintainer) · ADR-0126 §7.3 heading \"attached to disable\" under-describes the guard; docs/adr/** untouched here."
]
}
ACCEPT: PR #20724 at d59c97a5 (the disable half) · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-09-29T22:14Z
Checklist, read on GitHub rather than from the report:
Shape: a draft onto main. 4 files, +419 / −48:
engine.ts;
flow-activation-ledger.test.ts;
the new changeset .changeset/20678-subflow-disable-parked-run.md;
a two-sentence correction of stage 1's pending note .changeset/20678-subflow-disable-sequence.md.
No governed path.
Line 1 is Fixes #20678, and line 2 is Clause-②: yes (narrowing). One footer; assigned os-justin; no label written.
Order of commits: pins red first (97222b88: 7 failed), then the fix (17268021), then the changeset, the citation fix and the microtask fix (d59c97a5).
Read at source, against triage's answer 5898655174:
One helper.parkedRunsOf answers both the verdict and the refusal's run list. It reads through readSuspendedRuns, the one reader of both stores: the in-process cache and the durable store. The listing door's body moved there unchanged.
Fails closed. The guard reads with 'throw', so a store that cannot be listed refuses the disable instead of reading as "no parked run". The listing door keeps its degrade posture.
Who guards. An enabled packaged caller still guards (step: disable it first). A disabled caller, by the ledger or by its status, guards only while it holds a parked run (step: cancel each named run through the ADR-0044 door, or let it finish). A disabled caller with no parked run no longer guards.
Unchanged:DELETE_RESTRICTED / 409 / subflowCallers. resume() and resumeInternal() are untouched.
The B6 rider. A status-disabled child is never cycle-exempt: the status check now runs before the ledger-cycle exemption.
Live: the card's own reproduction now completes (409 naming the armed caller, then 200, then 200). The map pair with a parked sign-off answered 409 naming the run and the door; after a cancel, the disable answered 200.
The dev's two open questions, answered:
The correction of stage 1's pending note: confirmed path, A.
Two of that note's sentences are made false by this PR: "Disabling a subflow is unchanged", and the unqualified cycle bullet that B6 now narrows. AGENTS.md corrects a release note in its own entry.
Check Changeset is red by design on this DELIBERATE CORRECTION class (scripts/check-empty-changeset.mjs, rule 2), and it is not a required context.
Per this seat's landing rules, the confirmation is the at-tier contract review PASS on this same head, provided its record names the note and judges each rewritten sentence. It does not wait for the maintainer. The review brief says so.
The check stays red, and the PR lands with it only under the three conditions:
the gate's own source says it is red by design (pr-automation.yml, route 0);
it does not run on merge_group;
a PR comment records the gate and the reason.
Clause-②: yes (narrowing) with a minor + BREAKING + ADR-0087 not-required: kept as delivered, A.
The disable half widens (a refused disable becomes accepted), and the B6 rider narrows one enable corner. That is exactly the grammar's yes (narrowing), "a diff that widens one surface and narrows another".
This seat's claim line (yes (widening)) was wrong for the second time on this card. The rider this seat ordered is the narrowing. The dev measured instead of copying, as the order asked.
the durable store's list reads at most 1000 paused rows;
a caller run that is executing (not parked) at the instant of the disable is in neither store;
a failed run with a restorable consumed suspension counts as terminal.
Each is noted, not measured, and none is class a/b/c with a reach.
ADR-0126 §7.3's heading → carried by this seat to the maintainer (round report). Not a card: ② would need the maintainer's decision, and no PR is waiting on it.
Landing waits for three things: every other check green on the head, the at-tier contract review (which also confirms the correction), and the red-landing conditions above. After the merge, Fixes #20678 closes this card.
PR #20724 merged through the merge queue as 36d043be on origin/main, and Fixes #20678 closed this card completed.
Review: the contract review PASS on the landed head is 5900305615. It also confirmed the DELIBERATE CORRECTION of stage 1's pending note. The one red check (Check Changeset, red by design, not required) is recorded on the PR in 5900329392.
Content check: the landed commit's git patch-id --stable equals that of the reviewed head d59c97a5 against its base.
What now holds, both directions of ADR-0126 §7.3's packaged-subflow guard:
An enabled packaged caller guards its subflow (step: disable it first).
A switched-off caller guards only while it holds a parked run, read from both run stores through one helper. The step is to cancel each named run through the ADR-0044 door, or to let it finish.
A store that cannot be listed refuses the disable.
The card's prescribed sequence completes. It was measured live on both shipped pairs.
Activity
objectstack-fleet commentedon Sep 29, 2026
Enable half landed ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T21:06ZPR #20711 (
Part of #20678) merged through the merge queue as679f95econorigin/main.5898430659.git patch-id --stableequals that of the reviewed head08ed6d7bagainst its base.RESOURCE_CONFLICT/409while a packaged subflow it calls is disabled. The refusal names a remedy the subflow's real state admits.@objectstack/service-automationships this as aminormarked BREAKING (Clause-②: no (narrowing)).Carried into the disable-half stage, from that review:
disabledPackagedSubflowshits the cyclecontinuebefore it reads the status. So the completable publish remedy is not named, and the caller is armed onto a status-disabled child. The cycle exemption answers only the ledger bits, so it should never exempt a status-disabled child. This is a corner of this card's own guard, taken as a rider with a pin.registerFlowdoor (③.3). A package upgrade, a publish or a hot reload can still arm a packaged caller onto a disabled child. The reach through a public door is not measured, so the stage measures it and reports it. It is not built unless measured and scoped.5898655174amends nothing in §7.3.Release: session
session_01XY5uCwTjZj7884yYtyur4H. The enable half has landed, and triage's answer5898655174(A) is in hand, so the card returns topm:queue. This seat re-claims it at once for the disable-half stage, on679f95ec.Generated by Claude Code
objectstack-fleet commentedon Sep 29, 2026
Claim: PM loop round 1 · stage 2 (the disable half, triage's answer A) · 2026-09-29T21:08Z
Session:
session_01XY5uCwTjZj7884yYtyur4HAccount:
os-justin(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-20678-subflow-disable-parked-runWorktree:
objectstack-issue-20678-s2Domain:
domain:servicesSeat:
domain:services#6021File surface:
packages/services/service-automation/src/engine.ts(packagedSubflowCallers,toggleFlow's disable guard,disabledPackagedSubflowsfor the cycle rider),packages/services/service-automation/src/flow-activation-ledger.test.ts,.changeset/20678-subflow-disable-parked-run.md(stop on breach; explain in the report)Container & model:
M,mode:subagent,model: opus(dispatch-gates --tierat679f95ec: no path-derived mandate; floor sonnet · default opus · ceiling fable)Clause-②: yes (widening). Taken as the seat's reading, to be confirmed by measurement: a disable refused today (a packaged callee whose only packaged callers are disabled and hold no parked run) becomes accepted, and nothing accepted today becomes refused. The dev declares what the delivered diff measures, and the at-tier contract review is owed before enqueue.
Thread-read: 5898894424
Serial constraints cleared: PR #20711 (the enable half, same functions) merged as
679f95ec, and this stage bases on it. Open PR #20695 touches onlybuiltin/decision-overlapping-edge-conditions.pin.test.tsin this package, a file this stage does not touch (census of all 11 open PRs at this claim). #20596'sservice-automationcitation stage stays serial behind this one. The seat's other in-flight item, #20596 stage 7 (plugin-approvals, in review), is disjoint.Generated by Claude Code
test(service-automation): pin the subflow disable guard on parked-run…
fix(service-automation): a switched-off caller guards its packaged su…
objectstack-fleet commentedon Sep 29, 2026
os-dev-report
{ "issue": 20678, "status": "done", "branch": "claude/issue-20678-subflow-disable-parked-run", "pr": "https://github.com/objectstack-ai/objectstack/pull/20724", "session": "session_01XY5uCwTjZj7884yYtyur4H", "premise_still_valid": true, "summary": "The disable half of the ADR-0126 §7.3 guard is built as triage's answer A reads it. In toggleFlow(name, false), a packaged caller guards when it is enabled (step: disable it first). It also guards when it is disabled, by the ledger or by its status alike, and holds a parked run (step: cancel each named run through the ADR-0044 door, POST /automation/CALLER/runs/:runId/cancel, or let it finish). A disabled caller with no parked run no longer guards. DELETE_RESTRICTED / 409 and subflowCallers are unchanged, and resume()/resumeInternal() are untouched. One helper, parkedRunsOf, answers both the verdict and the refusal's run list. It reads through the one reader of both run stores, readSuspendedRuns: the body listSuspendedRunsDurable already served, moved unchanged. The listing keeps its degrade posture, and the guard throws on an unlistable store, so an outage never reads as 'no parked run'. The B6 cycle rider is in. Measured live on the showcase: the card's own reproduction now completes (409 naming the armed caller, 200, 200). The map pair with a parked sign-off answered 409 naming the run and the door; the cancel answered 200 cancelled:true, and then the disable answered 200. Clause-② measures 'yes (narrowing)' (not the claim's 'yes (widening)'). The B6 corner refuses an enable that was accepted.", "tests": "All at head d59c97a50. Red first: 97222b887 against the unfixed engine gave 'Tests 7 failed | 40 passed (47)', each failure for the intended reason (the run id or the new step missing, the store error masked by the old DELETE_RESTRICTED, the B6 enable accepted). The fix is 172680217. pnpm --filter @objectstack/service-automation test: 'Test Files 155 passed (155)', 'Tests 1942 passed (1942)'. pnpm --filter @objectstack/service-automation run typecheck: exit 0 (tsc --noEmit, then 'check:test-typecheck: OK'). --listFiles counts flow-activation-ledger.test.ts once in tsconfig.json and once in tsconfig.test.json. Ablation: 7 legs via scripts/ablation-replace.mjs wrap mode (anchor hit x1, mutation proven on disk as anchor 1 to 0 and a blob change, restore proven: 'ok restored: blob == HEAD (7d48c737834c) and git diff HEAD is empty'), with an outer trap on EXIT/INT/TERM restoring by absolute path, re-run from committed d59c97a50. No dist leg: the test imports ./engine.js relatively, so it resolves src. M1 every packaged caller guards: 5 red (pins 1-5). M2 hot cache only: 2 red (durable-only, unlistable store). M3 degrade posture: 1 red (unlistable store). M4 cycle exemption asked of a status-disabled child: 1 red (B6). M5 no run ids in the refusal: 4 red (pins 2-5). M6 a disabled caller never guards: 4 red (pins 2-5). M7 'disabled' read from the ledger bit alone: 1 red (status-disabled caller). Live, a showcase boot (pnpm dev -- --fresh -p 41863, built at d59c97a50, stopped by its recorded process group): the card's reproduction and the map pair sequence, as in the summary.", "mcp_calls": "0 — no MCP GitHub tool was called; all GitHub reads were REST GETs to api.github.com for objectstack-ai/objectstack only", "api_writes": "3 relay dispatches (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]) carrying 3 endpoint writes: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls (draft) = PR #20724, body read back byte-identical (16179 chars); (2) label-write, POST /repos/objectstack-ai/objectstack/issues/20724/assignees [os-justin], read back as matching, no label written; (3) post-stamped, POST /repos/objectstack-ai/objectstack/issues/20678/comments (this report). Plus git push (not REST): the empty-branch probe, then 5 commit pushes.", "gates": { "head": "d59c97a50", "derived": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at d59c97a50 gave 62 commands, the same 62 as pm-gates-at-dispatch.txt. All 62 ran with the exit captured before any pipe. --ran: '62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3)'. 61 exit 0. check:dual-build-cjs-loads and check:type-check-debt first exited 3 (PREREQUISITE NOT MET); after a full package build (turbo run build, 71/71 tasks, 45 cached) both exited 0 ('4 ledger entr(ies) re-measured ... none above its recorded number'). One designed red: node scripts/check-empty-changeset.mjs --base origin/main exits 1 on the DELIBERATE CORRECTION of the pending stage-1 changeset (see deviations and open_questions).", "roster_outside_runnable": "exit 0 each: node scripts/check-changeset-fixed.mjs; pnpm check:authz-resolver; pnpm check:error-code-casing ('no unlisted lowercase error codes in 6989 scanned file(s)'); pnpm check:filter-alias-parity", "extra": "exit 0 each: pnpm check:durability-log-level; pnpm check:startup-registry-verdict; node scripts/check-changeset-no-major.mjs --base origin/main --event (a synthetic pull_request payload carrying the PR body: 'LEVEL AXIS: this PR declares clause-② yes (narrowing), and no package whose packages/**/src/** it moves is graded patch'); check-adr-0087-registration with the same payload ('1 declared-breaking changeset(s), each carrying an ADR-0087 disposition', not-required (no-migration-prescription))", "package": "pnpm --filter @objectstack/service-automation test exit 0; pnpm --filter @objectstack/service-automation run typecheck exit 0", "lint": "declared narrowing: eslint --no-inline-config --format json on the 4 changed files gave 4 files, 0 errors, 2 warnings (both .md changesets: 'File ignored because no matching configuration was supplied'). Population from eslint.config.mjs files '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'. The config never enables type-aware linting (its own comment; no parserOptions.project), so the diff cannot move a verdict on an untouched file.", "not_measured": "the 6 value-bearing CI invocations dispatch-gates marks not runnable locally (check-issue-citations --census, 3 check-shard-attestation --emit, 2 check-test-completeness); reason: their argv comes from the workflow. CI on PR #20724: not awaited (in_progress at report time).", "nul_bytes": "pnpm check:nul-bytes exit 0; a control-byte self-scan of the 4 files found nothing" }, "line_budget": "n/a: no skills/** or line-ratcheted ledger touched. Diff vs base 679f95ec5: +419 / -48 (467 changed lines, under the 5000 human-merge threshold).", "files_changed": [ "packages/services/service-automation/src/engine.ts (+167/-45)", "packages/services/service-automation/src/flow-activation-ledger.test.ts (+229/-1)", ".changeset/20678-subflow-disable-parked-run.md (+21, new)", ".changeset/20678-subflow-disable-sequence.md (+2/-2, deviation: the pending stage-1 note)" ], "deviations": [ "Outside the declared surface: the pending stage-1 changeset .changeset/20678-subflow-disable-sequence.md is amended in place, because this diff makes two of its sentences false ('Disabling a subflow is unchanged', and the cycle bullet under 'Not refused' that the B6 corner now contradicts). Per AGENTS.md a release note is corrected in its own entry. check-empty-changeset stays red on it by design until the seat confirms on the PR (open question 1).", "Clause-② declared 'yes (narrowing)', not the claim's 'yes (widening)'. Measured: the disable widens, and the B6 rider narrows the enable accept set in one corner (a child disabled both ways inside a ledger cycle was skipped whole, and is now named).", "Inside engine.ts but outside the functions the claim names: listSuspendedRunsDurable is split into a thin public wrapper plus the private readSuspendedRuns(onEnumerationFailure), the one reader of both stores (B1). One paragraph of its catch comment, which claimed the method has no production consumer, is corrected (plugin-approvals reads it and fails closed).", "The disable guard's parked-run read is awaited only when a switched-off caller is judged. A first version awaited it on every disable, and flow-terminal-messages.test.ts (it calls toggleFlow without await) went red on the one-microtask shift. Fixed in the engine (d59c97a50), not in that test.", "A moved envelope comment re-added the citation #10243, which check-issue-citations reports as allocated-but-absent. It now cites the toggle ruling's commit 266436a7f and its in-tree record (isFlowAuthoringWrite).", "Throwaway probe test files were written under src/ to print the refusal texts and measure B2/B7 at the engine seam. They were removed and never committed (tree verified clean before every commit).", "A full workspace build (turbo, 71 tasks) was run for the two exit-3 gates and for the live boot. One showcase dev server ran on port 41863 and was stopped by its recorded process group (session 32651)." ], "cleanup": "worktree objectstack-issue-20678-s2 removed (node_modules deleted, then 'git worktree remove' without --force, exit 0). Remote head == local head d59c97a50 was verified before removal. The dev server was stopped, with no listener left on 41863. The report comment was posted with the shared checkout's post-stamped.mjs (blob-identical to the worktree's copy), executed only.", "open_questions": [ { "question": "Confirm the deliberate correction of the pending stage-1 release note .changeset/20678-subflow-disable-sequence.md in PR #20724 (check-empty-changeset stays red until confirmed). Four axes. Business: the next release's CHANGELOG would otherwise say 'Disabling a subflow is unchanged' in the same release that changes it, and would list a both-ways cycle child as 'Not refused' while it is refused. Long-term: AGENTS.md corrects a release note in its own entry, never by an erratum in a later one. AI-proofing: an upgrading agent greps the entry it lands on, so a false sentence there misleads with no counter-signal. Startup scope: two sentences in one file, no new mechanism.", "options": [ "A: keep the correction; the seat confirms it on PR #20724 and lands with that one non-required red", "B: revert that one file; the false sentences ship, and this PR's own changeset carries the true statement" ], "recommendation": "A, because it keeps the release note true where readers land (AGENTS.md), at the cost of one confirmation; B ships a known-false sentence." }, { "question": "Confirm the Clause-② declaration 'yes (narrowing)' with a minor + BREAKING changeset and the ADR-0087 marker not-required (no-migration-prescription). The claim line read 'yes (widening)'. The narrowing is the B6 rider, the order's in-scope corner. Four axes. Business: the corner is real (an enable accepted onto a child that stays disabled). Long-term: declaring the narrowing is what the arm exists for. AI-proofing: an undeclared narrowing is the #16421 failure. Startup scope: no extra mechanism.", "options": [ "A: keep as delivered", "B: drop the B6 rider to make the diff a pure widening (the corner stays open)" ], "recommendation": "A, because the rider was ordered in scope, and it measurably narrows. check-changeset-no-major reads the declaration as delivered and exits 0." } ], "out_of_scope_findings": [ "class: a · reach: public door measured live: a create request through the automation create door that asserts package provenance answered 200 [request detail redacted by the domain:services seat under the security-family disclosure rule; the finding is carried abstractly by #20761]. It created a flow the engine treats as packaged (its re-enable is refused 409 RESOURCE_CONFLICT by the enable guard), calling the ledger-disabled showcase_one_task_signoff, registered enabled:true. POST /api/v1/automation/NAME/trigger then answered 400 FLOW_FAILED 'subflow showcase_one_task_signoff failed: Flow ... is disabled'. The toggle door refuses that same state. · evidence: B7, the registerFlow door; the engine seam is also measured (a new packaged caller is bound onto a ledger-disabled child, and a subflow republished obsolete under an armed packaged caller fails the caller at its node). The real producer (a package upgrade adding a caller) is not named. DELETE /api/v1/automation/NAME (unregisterFlow) of a packaged subflow is the same invariant's third door, read and not measured. §7.3 holds on toggleFlow only; one family card fits all three doors. · dedupe words: registerFlow subflow guard; packaged caller armed disabled subflow; automation create door FLOW_DISABLED; ADR-0126 7.3 door", "class: a · reach: public door measured live: POST /api/v1/automation/NAME/toggle on a flow created without package provenance answered 400 VALIDATION_FAILED 'Package is required' (field package_id), for both enabled:false and enabled:true. The caller sent no package field; the activation row is written with an empty package id. This predates the PR and is not changed by it. · evidence: the same showcase boot, flow zz_probe_b7_customer (deleted after). · dedupe words: toggle Package is required; activation ledger customer flow toggle; sys_metadata_activation package_id", "carrier: none · boundary: ObjectStoreSuspendedRunStore.list() reads at most 1000 paused rows, so beyond that (deployment-wide) a caller's parked run can be missing from the enumeration and the disable would be accepted. Not measured. Noted in the PR Acceptance notes, not filed.", "carrier: none · boundary: a caller run executing (not parked) at the instant of the disable is in neither store and can still reach its subflow node. Triage ruled on \"holds a parked run\"; not built around. Noted, not filed.", "carrier: none · boundary: a failed caller run with a restorable consumed suspension (the restore-suspension door) is terminal and does not count; restoring it after the child is disabled re-parks it into a failure. Read, not measured. Noted, not filed.", "carrier: the seat (raised with the maintainer) · ADR-0126 §7.3 heading \"attached to disable\" under-describes the guard; docs/adr/** untouched here." ] }Generated by Claude Code
objectstack-fleet commentedon Sep 29, 2026
ACCEPT: PR #20724 at
d59c97a5(the disable half) ·domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T22:14ZChecklist, read on GitHub rather than from the report:
main. 4 files, +419 / −48:engine.ts;flow-activation-ledger.test.ts;.changeset/20678-subflow-disable-parked-run.md;.changeset/20678-subflow-disable-sequence.md.No governed path.
Fixes #20678, and line 2 isClause-②: yes (narrowing). One footer; assignedos-justin; no label written.97222b88: 7 failed), then the fix (17268021), then the changeset, the citation fix and the microtask fix (d59c97a5).5898655174:parkedRunsOfanswers both the verdict and the refusal's run list. It reads throughreadSuspendedRuns, the one reader of both stores: the in-process cache and the durable store. The listing door's body moved there unchanged.'throw', so a store that cannot be listed refuses the disable instead of reading as "no parked run". The listing door keeps its degrade posture.DELETE_RESTRICTED/409/subflowCallers.resume()andresumeInternal()are untouched.The dev's two open questions, answered:
Check Changesetis red by design on this DELIBERATE CORRECTION class (scripts/check-empty-changeset.mjs, rule 2), and it is not a required context.pr-automation.yml, route 0);merge_group;Clause-②: yes (narrowing)with aminor+ BREAKING + ADR-0087not-required: kept as delivered, A.yes (narrowing), "a diff that widens one surface and narrows another".yes (widening)) was wrong for the second time on this card. The rider this seat ordered is the narrowing. The dev measured instead of copying, as the order asked.Findings, line by line:
Each is noted, not measured, and none is class a/b/c with a reach.
Landing waits for three things: every other check green on the head, the at-tier contract review (which also confirms the correction), and the red-landing conditions above. After the merge,
Fixes #20678closes this card.Generated by Claude Code
objectstack-fleet commentedon Sep 29, 2026
Landed ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T22:50ZPR #20724 merged through the merge queue as
36d043beonorigin/main, andFixes #20678closed this cardcompleted.5900305615. It also confirmed the DELIBERATE CORRECTION of stage 1's pending note. The one red check (Check Changeset, red by design, not required) is recorded on the PR in5900329392.git patch-id --stableequals that of the reviewed headd59c97a5against its base.What now holds, both directions of ADR-0126 §7.3's packaged-subflow guard:
36d043be):679f95ec): re-enabling a packaged caller onto a disabled packaged subflow is refused, naming a remedy the subflow's real state admits. A status-disabled child is never cycle-exempt.For the release list:
@objectstack/service-automationships both halves asminor, marked BREAKING:Clause-②: no (narrowing);yes (narrowing).Carried elsewhere:
Unblocks here: #20596's
service-automationcitation stage.In the same act, this seat removes
pm:dispatchedand the assignee.Generated by Claude Code
fix(service-automation)!: refuse re-enabling a packaged flow onto a d…
fix(service-automation)!: a switched-off packaged caller guards its s…
fix(service-automation)!: ADR-0126 §7.3 holds on the registration and…
fix(service-automation)!: the toggle door switches packaged flows onl…