Skip to content

fix(service-automation)!: ADR-0126 §7.3 holds on the registration and removal doors, and the disable guard reads a caller's parked runs completely - #20759

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20725-subflow-guard-every-door
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20725-subflow-guard-every-door

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20725
Clause-②: yes (narrowing)

ADR-0126 §7.3 refuses one state: a packaged flow armed while a packaged flow it calls (the flowName of a subflow or map node) is disabled, so the caller fails at that node on the child's refusal. toggleFlow enforced it in both directions (#20678). This PR makes the other doors hold it too, as triage's direction (5901347976) reads it, and makes the disable guard's parked-run read complete (the note 5900362155).

What changed

Registration: one gate, not a refusal (engine.ts).

  • activateFlowTrigger, the single arming gate since service-automation: a restart re-arms the trigger of a ledger-disabled packaged flow — registerTrigger ignores the activation ledger, and every matching event then logs an ERROR claiming a run-history record that is never written #20677, now also declines a packaged flow while a packaged subflow / map target is disabled. It reads that through disabledPackagedSubflows, the helper refuseEnableOntoDisabledSubflow uses. There is no second reading of "A calls B".
  • The flow still registers. /_status reports enabled: true, bound: false with a reason naming each subflow and its remedy. The kernel:bootstrapped audit prints the same reason, never "binding failed". The engine warns once per decline.
  • Create, republish, upgrade, hot reload, kernel:ready trigger registration and the enable toggle all cross this gate.
  • The declined caller is re-armed (D2). Whenever a subflow changes state, the gate is asked again for that subflow's packaged callers: registerFlow, toggleFlow and hydration all do this. So enabling the subflow arms a declined caller. Republishing the subflow obsolete, or the ledger switching it off at boot, disarms an armed caller.
  • The cycle exemption in disabledPackagedSubflows now applies only while the caller itself is ledger-disabled, the one state in which the loop closes. The enable refusal only ever asks it of such a caller, so its answers are unchanged. The arming gate asks it of an enabled flow, where no enable order is blocked, so the exemption never applies there.

Removal (unregisterFlow, the DELETE /api/v1/automation/:name door). A packaged subflow that a packaged caller can still reach is refused synchronously with #20678's family: DELETE_RESTRICTED / 409, subflowCallers, and a step that completes. refuseDisableUnderReachingCallers became refuseUnderReachingCallers(name, act, …), and its disable text is byte-identical.

  • An enabled caller guards: disable it first.
  • A switched-off caller guards while the subflow is still enabled. The door is synchronous by the IAutomationService.unregisterFlow contract, so it cannot read parked runs. It names the disable door instead, which reads them completely and names each one to cancel, then the removal.
  • Once the subflow is switched off, a switched-off caller does not guard. A run that resumes into a disabled subflow already fails on its refusal, so removal breaks nothing more.

The complete read (suspended-run-store.ts, engine.ts). SuspendedRunStore gains an optional listByFlow(flowNames), complete by contract or an error.

  • ObjectStoreSuspendedRunStore.listByFlow seek-walks (keysetWalk, id order, no offset) the (flow_name, status) index that sys_automation_run declares. It reads to the end and throws if the walk cannot advance.
  • readSuspendedRuns takes an optional scope, and parkedRunsOf asks for the named callers only. A store without the member is read through list(), whose contract is "all".
  • The deployment-wide listing (listSuspendedRunsDurable) and its cap are unchanged. The cap was not raised.

The artifact reload (plugin.ts, one call site, a declared deviation). resyncFlowsFromProtocol now removes vanished flows through a new AutomationEngine.withdrawFlow, which is not guarded. See D3 below for why.

The dispatch's mechanism assumptions, measured (tree 01e78dce, then this branch)

  • D1 holds, with one gap closed. Registration (boot pull, the kernel:ready protocol sync, the metadata:reloaded resync, and POST / PUT / clone) reaches the gate through registerFlow. The kernel:ready trigger reaches it through registerTrigger, and the enable toggle through toggleFlow. rollbackFlow never arms and has no production caller.
    • Hydration did not reach the gate. It only unbound the disabled flows themselves. On a stock boot that is harmless, because triggers register at kernel:ready, after hydration. A host with a trigger registered before the pull kept a caller armed, so hydration now re-asks the gate for the callers of what it switched off.
    • What the gate declines on: every packaged subflow / map target that is not isFlowEnabled, whether ledger-disabled or status-disabled (obsolete / invalid). Only a packaged caller is judged. The cycle exemption never applies at arming (above). Triage's text said "ledger-disabled". Reusing the helper, as triage requires, brings the status case too.
  • D2 held. Nothing re-judged a declined caller, so re-arming is built through the same gate. No new shape was needed.
  • D3.
    • unregisterFlow is : void in the spec contract. The route calls it without await and then answers 200 { deleted: true }. An async refusal would have been unhandled and answered 200.
    • The shape chosen: the removal stays synchronous and routes the parked-run question to the disable door. It does not read parked runs a second way (see above).
    • Other callers. The only other production caller is plugin.ts's metadata:reloaded resync, which serves package upgrade and uninstall, Studio package publish and dev reload. It is not gated. It removes through withdrawFlow, because what it removes is the package's own decision. The next cold boot would not register the flow either, so a gate could only delay the state by one restart. It would also make an uninstall depend on listing order: a caller and its subflow leave together, and whichever is asked first would refuse the other.
  • D4. The store's list() reads { status: 'paused' } with limit: 1000 and no order. The (flow_name, status) index exists for exactly this question. The hot cache (suspendedRuns) holds every run this process parked, uncapped, so for this process it answers the scoped question completely. It is filtered by the same scope.
  • D5 was measured live and is reported to the seat, abstractly (Acceptance notes). It is not fixed here.
  • D6: Clause-②: yes (narrowing), minor, BREAKING, ADR-0087 not-required (no-migration-prescription). The claim read no (narrowing), so this is a deviation.
    • Why yes: the public surface widens. There is a new public AutomationEngine.withdrawFlow, a new public ObjectStoreSuspendedRunStore.listByFlow, and a new optional member SuspendedRunStore.listByFlow.
    • The narrowing: registration paths now decline what they armed, unregisterFlow / DELETE refuses what it accepted, and the disable refusal now sees a parked run it missed past the cap.
    • Nothing an author writes is removed or renamed.

Live, on a showcase boot (pnpm dev -- --fresh, built at 3fa3860a)

  • DELETE /api/v1/automation/showcase_notify_owner answered 409 {code: DELETE_RESTRICTED, httpStatus: 409}, naming showcase_task_done_notify_owner, and the flow was still served.
  • Following the steps: toggling the caller off gave 200. DELETE then gave 409, "Switch 'showcase_notify_owner' off first". Toggling the subflow off gave 200, and DELETE gave 200 {deleted: true}.
  • Republish: PUT of showcase_project_closure with status obsolete, then toggling showcase_closure_signoff off (200), then PUT of the caller with status active. /_status read enabled: true, bound: false with the reason, and the server log carried one warning naming the subflow.
  • Toggling showcase_closure_signoff on then read the caller bound: true.
  • The dev server was stopped by its recorded process group.

Tests (every reading at 3dc488eb, the head)

  • Pins, red first. c416228d held 21 pins; against the unfixed engine, 16 failed for the intended reason and the 5 controls passed. The fix is 3fa3860a.
    • 230ef858 added two assertions after the fix: once-per-decline, and listByFlow refusing to answer short. Each is red at base by construction, and each is turned red by its own ablation leg.
    • The pin files are subflow-guard-every-door.test.ts (new sibling; the store double gains $and, $gt and one ascending orderBy, refusing everything else) and suspended-run-store.test.ts.
    • Every refusal pin asserts code and status.
  • pnpm --filter @objectstack/service-automation test: Test Files 156 passed (156), Tests 1964 passed (1964). 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's and service-automation: a restart re-arms the trigger of a ledger-disabled packaged flow — registerTrigger ignores the activation ledger, and every matching event then logs an ERROR claiming a run-history record that is never written #20677's pins stay green.
  • pnpm --filter @objectstack/service-automation run typecheck: exit 0. --listFiles counts both pin files in tsconfig.json and in tsconfig.test.json.
  • Ablation. 16 legs, through scripts/ablation-replace.mjs in wrap mode, with an outer trap on EXIT / INT / TERM restoring by absolute path. Every leg: anchor x1 then x0, the blob changed, then "ok restored: blob == HEAD and git diff HEAD is empty". There is no dist leg: the pins import ./engine.js relatively.
    • M1: the gate call removed, 10 red.
    • M2: every packaged caller declined, 12 red, including the enabled-subflow control and the uninstall control.
    • M3: the packaged-caller check dropped, 1 red (the customer control).
    • M4: re-judging disabled, 6 red.
    • M5: the cycle precondition dropped, 1 red.
    • M6: warn-once dropped, 1 red.
    • M7: the /_status reason dropped, 3 red.
    • M8: the removal guard dropped, 3 red.
    • M9: a switched-off caller never guards removal, 2 red.
    • M10: the subflow's own switch ignored, 3 red, including the switched-off-removal control.
    • M11: customer callers counted, 1 red (the control).
    • M12: the resync through the guarded door, 1 red (uninstall).
    • M13: the unscoped read, 1 red (the beyond-the-cap guard).
    • M14: the flow filter dropped, 2 red.
    • M15: one page only, 2 red.
    • M16: the truncation refusal dropped, 1 red.
    • In an earlier round, the first M3 and M11 attempts were no-ops that the tool refused (replacement count unmoved; anchor hit twice). Nothing ran on them, and both were re-anchored.

Gates (at 3dc488eb)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack gives 62 commands, identical to the dispatch-time list. All 62 ran with the exit captured before any pipe, and all exited 0. --ran: "62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED".
  • The roster families outside the runnable list, each exit 0: node scripts/check-changeset-fixed.mjs, pnpm check:authz-resolver, pnpm check:error-code-casing, pnpm check:filter-alias-parity. Also exit 0 for the log-level and recorded-decline edits: pnpm check:durability-log-level and pnpm check:startup-registry-verdict.
  • check-changeset-no-major and check-adr-0087-registration were run against a synthetic pull_request payload carrying this body; both exit 0.
  • Lint, a declared narrowing. eslint --no-inline-config --format json over the 6 changed files gave 0 errors and 1 warning (the changeset .md has no matching config). The population comes from eslint.config.mjs files **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}. The config enables no type-aware linting (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 as not runnable locally, because their argv comes from the workflow.

Changeset

.changeset/20725-subflow-guard-every-door.md: @objectstack/service-automation minor, BREAKING, with the Clause-② line and the ADR-0087 disposition. The two pending #20678 notes stay true: the enable toggle in a cycle is still not refused, and the parked-run read still covers both stores. They are unchanged.

Acceptance notes

  • class: a · reach: the automation create door, measured live on the showcase. A create request that asserts package provenance yields a flow that the §7.3 guards treat as shipped, beyond its classification. It holds a shipped subflow's disable and, after this PR, its removal: the "a flow the customer authored does not hold a packaged one hostage" filter does not apply to it. The toggle door accepts it and writes an activation row attributed to the asserted package, where an unasserted flow is refused. The same request without the assertion does neither.
    • Precedence against a same-named shipped flow does not depend on the assertion: a create by name overwrites the in-process definition either way, and the assertion decides only whether the overwrite stays classified as packaged. Read-only package treatment does not apply on these doors to either.
    • The seat files this as its own security card, with no request detail anywhere public. It is not fixed here.
  • carrier: none · boundary, noted: ObjectStoreSuspendedRunStore.list() still reads one capped page of 1000 paused rows. The deployment-wide listing reads it, and so does the boot wait-timer re-arm (builtin/wait-node.ts, rearmSuspendedWaitTimers). Past that, a wait's timer would not be re-armed. Not measured.
  • carrier: none · boundary, noted: a caller onto a subflow that is not registered at all (withdrawn by an artifact reload, or never shipped) is not declined. The gate reads registered packaged children only, and a missing target's provenance is unknowable. Such a caller fails at its node, as before.
  • carrier: none · boundary, by construction: while a subflow is enabled, removing it asks for it to be switched off first even when its switched-off callers hold no parked run. That is one extra completable step, and the price of the synchronous removal contract.
  • Base drift: origin/main has moved one commit (697845d1, a service-package citation re-anchor and its changeset). It does not touch service-automation or the automation route, and it is not merged here. The merge queue validates the merge ref.

Generated by Claude Code

…emoval doors, and a complete parked-run read (red)

Pins, committed ahead of the fix and red against it:

- registration: create, republish, the subflow republished obsolete,
  a cold boot, hydration, and an artifact reload each reach a packaged
  caller onto a disabled packaged subflow; the caller must register
  unarmed, with its /_status reason and one warning naming the subflow;
- a declined caller is armed once its subflow is enabled (subflow and
  map pairs, two subflows, a ledger-disabled cycle);
- removal: unregisterFlow of a packaged subflow a packaged caller can
  still reach is refused with DELETE_RESTRICTED / 409, and the named
  steps complete;
- the parked-run read behind the disable guard answers a named caller's
  run that lies beyond the first 1000 paused rows of other flows.

Controls (green here): an enabled subflow still arms its caller; a
customer-authored caller is armed; removal with no packaged caller, or
of a switched-off subflow with switched-off callers; an uninstall reload
unregisters both flows in either order.

The store double gains `$and`, `$gt` and one ascending orderBy key,
refusing every other combinator.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
… removal doors, and the disable guard reads a caller's parked runs completely

- Registration: `activateFlowTrigger`, the one arming gate, declines a
  packaged caller whose packaged subflow/map target is disabled, read
  through `disabledPackagedSubflows` (the enable refusal's own reading).
  The flow still registers; `/_status` reports `bound: false` with the
  reason, and one warning names each subflow and its remedy.
- A declined caller is re-offered to the gate whenever its subflow changes
  state (registerFlow, toggleFlow, hydration): enabling the subflow arms
  it; republishing the subflow `obsolete`, or the ledger switching it off
  at boot, disarms an armed caller.
- The ledger-cycle exemption in `disabledPackagedSubflows` applies only
  while the caller itself is ledger-disabled, the one state in which the
  loop closes; the enable refusal's answers are unchanged.
- Removal: `unregisterFlow` (the DELETE door) refuses, synchronously, a
  packaged subflow a packaged caller can still reach, with the disable
  direction's family (`DELETE_RESTRICTED` / 409). An enabled caller
  guards; a switched-off caller guards while the subflow is still
  enabled, and the step named is the disable door, which reads its parked
  runs. The artifact reload removes through the new `withdrawFlow`,
  unguarded.
- The disable guard's parked-run read asks for the named callers' runs:
  `SuspendedRunStore.listByFlow` (optional, complete by contract);
  `ObjectStoreSuspendedRunStore.listByFlow` seek-walks the
  `(flow_name, status)` index to its end and throws rather than answer
  short.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…on every door

Clause-②: yes (narrowing), ADR-0087 not-required (no-migration-prescription).

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…ing and listByFlow's refusal to answer short

Two branches the first pins did not reach: re-registering a declined
caller unchanged says nothing new, and a seek walk that cannot advance
past a full page throws instead of returning what it read.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…only the reason it reports

The subflow list it also stored had no reader; the reason names the
subflows, and the once-per-decline warning compares the reason.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 28 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/declarative-endpoints.mdx (via /api/v1/automation/:name/trigger (route, bridged from symbol toggleFlow — its route source's handler names it))
  • content/docs/api/error-catalog.mdx (via RESOURCE_CONFLICT (literal, a string literal in refuseEnableOntoDisabledSubflow))
  • content/docs/api/error-handling-server.mdx (via RESOURCE_CONFLICT (literal, a string literal in refuseEnableOntoDisabledSubflow))
  • content/docs/automation/flows.mdx (via registerFlow (symbol, a method of class AutomationEngine), /api/v1/automation/:name/trigger (route, bridged from symbol toggleFlow — its route source's handler names it))
  • content/docs/data-modeling/formulas.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/automation/:name/trigger (route, bridged from symbol toggleFlow — its route source's handler names it))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class), registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-4.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-5.mdx (via registerFlow (symbol, a method of class AutomationEngine))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: /automation/:name (route, 36 pages)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 33e4a5609c4d6cc012279f08e24e111c3370d14f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1e31e520ade1a818d16643b0a89eab6ca2c593ec — the merge of head 3dc488eb4e600c9c885decfb8b92a3fd0e129d1e into base 33e4a5609c4d6cc012279f08e24e111c3370d14f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1e31e520ade1a818d16643b0a89eab6ca2c593ec && git checkout 1e31e520ade1a818d16643b0a89eab6ca2c593ec
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 33e4a5609c4d6cc012279f08e24e111c3370d14f 3dc488eb4e600c9c885decfb8b92a3fd0e129d1e && git checkout -B drift-repro 33e4a5609c4d6cc012279f08e24e111c3370d14f && git merge --no-ff 3dc488eb4e600c9c885decfb8b92a3fd0e129d1e

node scripts/docs-audit/affected-docs.mjs --json 33e4a5609c4d6cc012279f08e24e111c3370d14f

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 33e4a5609c4d6cc012279f08e24e111c3370d14f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3dc488eb4e600c9c885decfb8b92a3fd0e129d1e
Local-runs: none

PR #20759, a draft onto main closing #20725 (Fixes #20725, line 1; Clause-②: yes (narrowing), line 2): 6 files, +1066 / −26 (1092 changed lines), merge base 01e78dcee, read as the diff against that base over the head fetched into this checkout's object store under an owned name (refs/review/pr-20759) — a read, no worktree, nothing built or run. No governed path; head repo = base repo. Inputs: the card's body and all five comments (the seat's cap note 5900362155, triage's direction 5901347976, the claim 5902084984, the dev report 5903162549, the seat's ACCEPT 5903195034), #20678's body and all eleven comments with its two at-tier records (5898430659 on PR #20711, 5900305615 on PR #20724), #20677's body, #20761's body (referred to, never detailed), the PR body and file list, and the head's check-runs. The dispatch order and the dispatching seat's conclusions were not inputs; where this record agrees with them the source agrees.

Check-runs on the head, latest run per name, read 2026-09-30T03:08Z (33 runs): 0 failure, 1 in progress. Six of the seven required contexts conclude success: Lint & Repo Gates, Test Core (rollup and six shards), Dogfood Regression Gate (rollup and three shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. The seventh, TypeScript Type Check, has not concluded: its Type Check · workspace sub-job is in_progress and the rollup check has not yet been created; Type Check · source gates, · consumer gates and · debt ledger are success. Also success: Check Changeset (the job that runs check-changeset-no-major and check-adr-0087-registration on the PR body — green on this head, so neither verdict in ② is read from silence), Dogfood Verify CLI, The card this PR closes must claim this branch, Part-of PR must not also close its card, both same-issue / single-writer checks, Check Documentation Links, Flag docs affected by code changes, Auto Label, Check PR Size, filter. skipped by design: Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in). The workspace typecheck is the one gate that compiles engine.ts, suspended-run-store.ts and both pin files (vitest and tsup do not type-check), so the landing below waits on it.

① Derived judgments

Read at source on the head: engine.ts (activateFlowTrigger, declineOntoDisabledSubflows, describeDisabledSubflows, rejudgeSubflowCallers, subflowDeclinedFlows, describeUnboundReason, getFlowRuntimeStates, getTriggerBindingAudit, registerFlow, registerTrigger, hydrateFlowActivations, unregisterFlow, withdrawFlow, refuseUnderReachingCallers, disabledPackagedSubflows, ledgerDisabledChainReaches, refuseEnableOntoDisabledSubflow, toggleFlow, rollbackFlow, parkedRunsOf, readSuspendedRuns, isFlowEnabled, subflowTargets, packagedSubflowCallers), suspended-run-store.ts (list, listByFlow), plugin.ts's resync loop, keysetWalk in packages/types/src/keyset-walk.ts, SysAutomationRun's indexes, IAutomationService.unregisterFlow in packages/spec/src/contracts/automation-service.ts, the DELETE /:name and toggle arms in packages/runtime/src/domains/automation.ts, the package root index.ts, ADR-0126 §7.2 / §7.3 / §9, and all 21 new pins. Each judgment is against triage's direction 5901347976.

  1. Registration is one gate and never a refusal — right. The decline sits in activateFlowTrigger, after the service-automation: a restart re-arms the trigger of a ledger-disabled packaged flow — registerTrigger ignores the activation ledger, and every matching event then logs an ERROR claiming a run-history record that is never written #20677 enablement gate (isFlowEnabled) and the scheduled-work policy gate, and before the trigger lookup, so every arming path crosses it: the boot pull, POST / PUT / clone and the metadata:reloaded resync (through registerFlow), the kernel:ready trigger registration (registerTrigger), and the enable toggle. registerFlow's accept set is unchanged — the flow is stored, versioned and answered before the gate is asked — so a boot or an upgrade cannot fail on an installation's choice; what narrows is the ARMING set: a packaged caller with a disabled packaged subflow / map target is no longer bound. The reading is disabledPackagedSubflows, the enable refusal's own helper (targets from subflowTargets, callers from packagedSubflowCallers, "disabled" from isFlowEnabled) — no second reading of "A calls B"; only a packaged caller is judged (describeFlowContender(flow).source === 'package'), and the customer-authored control pin exercises the real discriminator.
  2. /_status, the audit and warn-once — right. describeUnboundReason returns the recorded reason for an enabled, unbound flow ahead of the trigger branches, so the row reads enabled: true, bound: false, reason: … naming each subflow and its remedy, and getTriggerBindingAudit reads the same sentence by construction ([finding] ruling G item 6's third surface has no platform half: _status carries no reason, so a policy-disabled flow is indistinguishable from a broken binding — and objectui#9217 is blocked on a card that does not exist #18235) — the COLD BOOT pin asserts the audit never says "binding failed". One warn per distinct decline, keyed on the reason text (previous === reason returns without logging), so a hot reload or the kernel:ready re-bind that declines for the same subflows is silent and a changed set of subflows warns again. warn is the right level under AGENTS.md's rule: functional, visible on /_status, nothing claimed-persisted is lost. One reading, not blocking: the record survives the caller's own switch-off (deactivateFlowTrigger does not clear it), which is inert for /_status (read only while enabled) and for arming (the enablement gate returns first) and is reset the next time the gate judges the flow; its one visible effect is that a decline repeated for the same reason after a switch-off and a cycle-exempt re-enable is not re-warned.
  3. D1's widening past triage's word "ledger-disabled" — right, and forced by the direction itself. Reusing the helper brings status-disabled children (obsolete / invalid), which is what the enable direction already refuses on (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 stage 2's B6 rider); a gate that armed onto a status-disabled child while the switch refused it would be a second reading. Declared as a deviation in the report and the body.
  4. Re-arming a declined caller (D2) — right. rejudgeSubflowCallers(subflow) runs from registerFlow(subflow), toggleFlow(subflow, *) and hydration (for each flow the ledger switched off). A bound caller is re-offered only when the gate would now decline it; an unbound caller only when it is in the decline record; both go through deactivateFlowTrigger + activateFlowTrigger, never around the gate. A caller unbound for another reason (ledger-disabled, no trigger, policy) is left alone, and a ledger-disabled caller offered by the record returns at the enablement gate — so nothing here can arm a switched-off flow. Hydration's re-judge closes the gap the dev measured (a host whose trigger registered before the pull), and the boot-pull ordering holds: a caller registered before its child is not declined (the child is unknowable), and the child's own registerFlow or the kernel:ready registerTrigger re-asks the gate once the child is present — the COLD BOOT and HYDRATION pins drive both orders.
  5. The cycle-exemption change — right, and necessary once the helper is shared. loopCanClose = flowLedgerDisabled.has(name): in the enable direction refuseEnableOntoDisabledSubflow returns unless the caller is ledger-disabled, so loopCanClose is always true there and that door's answers are byte-identical — 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's cycle, self-call and chain pins stand on unchanged logic. At the arming gate the flow is enabled (toggleFlow clears the ledger bit before activateFlowTrigger; the enablement gate returned otherwise), so the exemption never applies and a disabled child in a cycle declines the caller. Without this line the reused helper would have armed ping onto a switched-off pong — the exact §7.3 state — and the cycle pin (enable accepted, caller unarmed until its partner is on, then both armed) is the proof.
  6. The removal door — right, in the disable direction's family, and synchronous by contract. unregisterFlow guards only a packaged subflow, reads its callers through packagedSubflowCallers and refuses through the renamed refuseUnderReachingCallers(name, 'remove', callers, new Map()) with DELETE_RESTRICTED / 409 / subflowCallers; the disable arm's text is unchanged ('disable' produces the same words as before). Who guards: while the subflow is enabled, every packaged caller — an enabled one with "disable it first", a switched-off one with "switch the subflow off first", which is the door that reads parked runs completely and names each one; once the subflow is switched off, enabled callers only. That is one reachability reading and one parked-run reader, as triage's "⛔ No second reading" asks. IAutomationService.unregisterFlow?(name: string): void is the spec contract, the route calls it without await and answers 200 {deleted: true}, and the toggle arm shows the dispatcher's declared-status passthrough is what carries a thrown code / status to the wire (stage 1's ①.3) — so a synchronous throw is the only shape that can refuse on this door at all, and the dev's live 409 DELETE_RESTRICTED reading agrees with the source. Accept set NARROWS: a removal accepted today is refused. "Enabled" here is the ledger/status bit, not the binding, so an enabled-but-declined caller also guards — over-refusal in the safe direction, with a completable step.
  7. withdrawFlow, and whether any path now removes a guarded subflow unguarded — right, and exactly one does, by design. At the head unregisterFlow has one production caller (the DELETE route), withdrawFlow has one (plugin.ts's metadata:reloaded resync, vanished flows), this.flows.delete( occurs once in engine.ts (inside withdrawFlow), and rollbackFlow sets a definition and neither removes nor arms. So the artifact reload is the only unguarded removal, and the reason holds: the package decided it, the next cold boot would not register the flow either, and a guarded resync would depend on listing order and have its refusal swallowed by the loop's best-effort catch, leaving the subflow registered until restart. The UNINSTALL pin drives both orders through a real LiteKernel boot. Public surface: withdrawFlow is a new public method on AutomationEngine, which the package root exports; it is not on IAutomationService, and a host holding the engine could call the previously unguarded unregisterFlow before this PR, so nothing is opened that was closed.
  8. listByFlow and its refusal to answer short — right, and it is triage's "don't just raise the cap". SuspendedRunStore.listByFlow? is optional and documented "complete or throw". ObjectStoreSuspendedRunStore.listByFlow walks each named flow's flow_name + status: 'paused' rows with keysetWalk (ascending id, $and-composed $gt seek, pages of 500, no max), reads to the end, and throws when walk.truncated — which keysetWalk sets only when a row lacks the key or the cursor fails to advance, i.e. exactly when the walk cannot vouch for completeness. SysAutomationRun declares fields: ['flow_name', 'status'] in its indexes, so the claim in the body and the docblock holds. readSuspendedRuns('throw', flowNames) prefers listByFlow, falls back to list() filtered where a store lacks it (the in-memory store's list() is uncapped, so 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's unlistable-store pin still bites), filters the hot map by the same scope, and rethrows on the guard's arm — so a truncation refusal refuses the disable and writes nothing. list() and its limit: 1000 are untouched. Accept set NARROWS in the corner past the cap: a disable accepted on a caller's absence from row 1001 is now refused. The test double refuses every operator it does not model, so $and, $gt and the single ascending orderBy are really exercised, and the fourth pin drives the whole guard on a restarted engine over the DB-backed store.
  9. The refusal text and codes — right. DELETE_RESTRICTED / 409 and RESOURCE_CONFLICT / 409 are standard-catalog members already in use here; no new ADR-0112 entry is minted. Every runtime string cites ADR ids only — every #20725 in the diff sits in a comment. describeDisabledSubflows gives the enable refusal, the warning and the /_status reason one wording.
  10. 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's and service-automation: a restart re-arms the trigger of a ledger-disabled packaged flow — registerTrigger ignores the activation ledger, and every matching event then logs an ERROR claiming a run-history record that is never written #20677's pins. flow-activation-ledger.test.ts is not in the file list, the service-automation: a restart re-arms the trigger of a ledger-disabled packaged flow — registerTrigger ignores the activation ledger, and every matching event then logs an ERROR claiming a run-history record that is never written #20677 enablement gate still precedes the new decline in activateFlowTrigger, and the enable refusal's logic is unchanged (①.5); their green is Test Core's verdict on this head — all six shards and the rollup success. The dev's 1964 / 1964 and 16 ablation legs are readings consistent with the pins and the code, taken as such under Local-runs: none.
  11. ADR-0126 — extended again, not reversed. §7.3 states the invariant (a vendor flow must not break mid-run at its subflow node) and records it "attached to disable"; §9's not-chartered list names nothing about arming or removal. This PR applies §7.3's own rationale at the doors that reach the same state; no ADR text is touched, none is owed by a code PR, and the heading's under-description (③.9) grows by two doors.
  12. Docs and hygiene. No hand-written page restates the removal or registration rule (content/docs, apps/docs, skills: 0 hits for the new prose, unregisterFlow, withdrawFlow, listByFlow; the only DELETE /api/v1/automation/:name line is the auto-generated reference). The Docs Drift comment is advisory and lists the generic RESOURCE_CONFLICT catalog pages and the trigger route. No model identifier in title, body, changeset, diff or commits (the five commits carry the model-free trailer pair); no .md outside .changeset/; no probe file; @objectstack/types is already a workspace:* dependency, so the new import is declared; packages/spec and packages/metadata-core are untouched, so no generated artifact is owed and describeFlowContender is the discriminator stage 1 read.

② Semver level

  • The changeset .changeset/20725-subflow-guard-every-door.md grades @objectstack/service-automation (17.5.0, published) minor, carries the BREAKING banner, Clause-②: yes (narrowing), and exactly one ADR-0087 marker, not-required (no-migration-prescription). The PR body's line 2 carries the same declaration, as the post-task checklist requires.
  • yes (narrowing) is RIGHT; the claim's no (narrowing) was wrong. Per scripts/pm/clause2-line.mjs the value answers whether the card widens an accept set or the public surface — it does: three new public members (AutomationEngine.withdrawFlow, ObjectStoreSuspendedRunStore.listByFlow, the optional SuspendedRunStore.listByFlow), all reachable from the package root. The arm names the narrowings that ride in the same diff — the arming set (①.1), the removal door (①.6) and the disable door past the cap (①.8). That is the grammar's third case, "widens one surface and narrows another"; no (narrowing) would have left the widening undeclared.
  • minor is RIGHT. AGENTS.md: yes takes at least minor; (narrowing) is BREAKING, shipped minor under the launch-window convention check-changeset-no-major.mjs enforces, with the banner and the disposition as the carriers. Check Changeset concluded success on this head, so the level axis and the disposition are gate-read, not inferred.
  • not-required (no-migration-prescription) is RIGHT. Nothing an author writes is removed or renamed; no FROM → TO mapping or tombstone is owed; the body's steps (disable, switch off, cancel, publish active) are operator steps, not consumer code rewrites, which is the one thing that category is refused on.
  • The changeset's sentences hold against the code, including "armed the moment its subflow is enabled, through the switch or by republishing the subflow active" (①.4), the two-subflow and cycle sentences (pins), "a flow the customer authored, or a subflow the customer authored, is not judged" (①.1, ①.6), and the (flow_name, status) index (①.8). The two pending 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 notes stay true (read at the head): the stage-1 note's "Not refused" list describes the enable door, whose answers ①.5 leaves unchanged; the stage-2 note's disable-direction sentences still describe that door, and its "read from both … stores" sentence is strengthened, not falsified. No correction is owed, so Check Changeset is green for the right reason.

③ Boundary flags

  1. Open question 1 (the removal door's shape) — ANSWERED: A stands. IAutomationService.unregisterFlow is : void in packages/spec; the route answers 200 after an un-awaited call; an async refusal would be an unhandled rejection behind a 200. B is a packages/spec contract change that also moves the route and the resync, with no measured pull, and would be its own card. A refuses synchronously, names a completable step on every arm, and reads reachability once. The cost — one extra step while an enabled subflow's switched-off callers hold no parked run — is declared in the body and the report.
  2. Open question 2 (yes (narrowing), minor, BREAKING, not-required) — ANSWERED: A stands (② above). The seat's own ACCEPT concedes the claim line, the third such correction in this lane; each time the miss was a surface the fix itself adds or narrows. The dev measured, as the order asked.
  3. Deviation: plugin.ts, one call site — accepted. ①.7: the guarded door would have made an uninstall order-dependent and swallowed the refusal; pinned in both orders and by ablation M12.
  4. Deviation: status-disabled children and the cycle precondition (D1) — accepted (①.3, ①.5).
  5. Deviation: the in-memory store does not implement listByFlow — accepted (①.8); a second reader there would only duplicate an uncapped list().
  6. Process deviations (assertions added after the fix, the refactor at the head, two re-anchored ablation legs, a mistyped gate not counted, full builds and one dev server stopped) — noted; the head's check-runs are the verdict on the tree.
  7. Out-of-scope finding, security family — FILED as automation: a flow created through the authoring door can assert package provenance, and the ADR-0126 guards and the activation ledger then treat it as package-shipped #20761, abstractly. This record adds no request detail and refers to automation: a flow created through the authoring door can assert package provenance, and the ADR-0126 guards and the activation ledger then treat it as package-shipped #20761 only; the PR body, the report and the ACCEPT are abstract too. For the seat: 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's stage-2 dev report predates that discipline and is less abstract than automation: a flow created through the authoring door can assert package provenance, and the ADR-0126 guards and the activation ledger then treat it as package-shipped #20761.
  8. Boundary: list()'s cap still backs the deployment-wide listing and the boot wait-timer re-arm (builtin/wait-node.ts), so past 1000 live suspensions a wait's timer would not be re-armed at boot. Not this PR's defect and not measured; carrier: none is the report's word. It is a silent functional loss of the same family this card came from, so the seat should read it for a carrier once a reach is measured — noted, not blocking.
  9. ADR-0126 §7.3's heading, "attached to disable" — carried by the seat to the maintainer; it now under-describes three more doors. Governed text, in no code PR.
  10. Boundary: a packaged caller onto a subflow that is not registered at all (withdrawn or never shipped) is not declined and fails at its node as before — by construction, declared. withdrawFlow does not re-judge the withdrawn subflow's callers for the same reason.
  11. Boundary: a run resuming into a subflow removed after it was switched off fails "not found" where it failed "disabled" before — declared in the body; breaks nothing more.
  12. Base drift: main is 6 commits past 01e78dcee; measured over the fetched refs, none touches packages/services/service-automation, the automation route, keyset-walk.ts, the spec contract or the two pending 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 notes. The queue's merge-ref build is the second CI round.
  13. UI rendering of the removal 409 on the Setup packaged-automation board — NOT MEASURED, declared. No route or body shape changed.
  14. The one open gate: TypeScript Type Check (Type Check · workspace in progress at the reading above). It is the compile of the four changed source and pin files. This record's PASS is on the diff; the landing waits for that context to conclude success, and a non-success reopens this record.

Implemented-by: claude/issue-20725-subflow-guard-every-door
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS

Landing: six of the seven required contexts are green on this head and TypeScript Type Check is the one still owed; once it concludes success this record covers the head and the owning seat lands it through the queue. The merge closes #20725; #20726 (same engine.ts) is dispatched on the merged code.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 03:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 0d9349f Sep 30, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20725-subflow-guard-every-door branch September 30, 2026 03:33
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ion run proved stale (objectstack-ai#21339)

Docs-only checklist revision from the 17.6.0 release-verification run
objectstack-ai#21330 (subject `617f25f8`, Console pin `31971ff1e28f`). Every change
follows the checklist README's lifecycle rule: the item's `revision`
bumps and one `history` entry says what changed, why, and cites the run.
No product code, no `content/docs/**`, no generated file.

## Stale clauses the run proved (each a FAIL in objectstack-ai#21330 with disposition
stale-clause / assertion-defect)

| item | rev | evidence | changed by |
|---|---|---|---|
| `access-security.audit-log-browser` | 2 → 3 | admin `GET
/data/sys_audit_log?filter={"action":"delete"}` → 0 rows; the row is
stored with correct attribution | `30c530e5` (objectstack-ai#21194): the ledger serves
a non-system reader, admins included, only rows about records it can
read |
| `api-backend.filter-comparand-conformance` | 2 → 3 | POST `/query` →
400 `VALIDATION_FAILED` at `query.where.f_number.$eq`; GET `$filter` and
engine → 400 `INVALID_FILTER`; no door returns rows | objectstack-ai#20116 (`cfc3bcf1`
objectstack-ai#20247, `dd1b8031` objectstack-ai#20325) — the split query-contract-matrix rev 3
already records |
| `api-backend.date-range-preset-matrix` | 1 → 2 | equality
`{"signed_on":"today"}` → 400 `INVALID_FILTER` (temporal door);
`$gte:"this_week"` → 400 with `bareDateRangePresetComparandMessage` | by
design: `18.filter-preset-ordering-comparand-refused.ts` judges ordering
positions only |
| `records-forms.import-transform-matrix` | 1 → 2 | 400
`UNSUPPORTED_TRANSFORM` names the missing sandbox, 0 rows — but no
`framework#2611` | `f115b1f` (objectstack-ai#21188): refusals state decisions in
words, not tracker numbers |
| `studio-authoring.view-authoring-live` | 1 → 2 | `GET
/meta/view?object=repair_asset` serves `repair_asset.default` /
`repair_asset.form` with the authored config; container name 0 hits | by
design: `expandViewContainer` (objectstack-ai#7163, objectstack-ai#7736, objectstack-ai#13407) |

## Expected-fail notes 17.6.0 has made pass (clauses held in objectstack-ai#21330;
only their framing was stale)

| item | rev | measured | fixed by |
|---|---|---|---|
| `automation.packaged-flow-subflow-disable-refusal` | 1 → 2 | caller
off → child's disable retry 200, ledger `active=false`; caller-first
enable 409 `RESOURCE_CONFLICT` | `36d043b` objectstack-ai#20724, `0d9349f` objectstack-ai#20759; the
enable guard is `679f95e` objectstack-ai#20711 (step 6 now enables the child first) |
| `automation.packaged-flow-clone-contract` | 1 → 2 | clone survives a
cold restart and fires; still unreachable from Studio | durability
`cb4c31d` objectstack-ai#20907; reachability now filed as objectstack-ai#21332 (clause unchanged,
still expected to fail) |
| `access-security.packaged-flow-write-door-parity` | 1 → 2 | `PUT` /
`DELETE /automation/showcase_urgent_task_alert` → 403 `NOT_OVERRIDABLE`,
flow unchanged | `4b45afae` (objectstack-ai#20817); knownGap names the existing pin
`packaged-flow-write-door-parity.dogfood.test.ts` |

No clause was weakened: each still refuses the original failure mode
(rows returned, a served delete row, a 200-with-zero-rows), and the
clone clause keeps its expected fail.

## Validation

- `node scripts/check-platform-checklist.mjs` → `OK — 15 areas, 269
items (265 active, 2 planned)`; symbol anchors and line-citation sweep
green.
- `api-backend.json` is re-serialized in its existing canonical 2-space
form; the other four files are edited in place in their existing mixed
formatting.

Not in this PR (listed on objectstack-ai#21330's close-out instead): the other
checklist-accuracy findings the run collected, and the two `planned`
picklist items, which can only be promoted by a run in which they pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants