Repository navigation
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
Conversation
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: PR #20759, a draft onto 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 ① Derived judgmentsRead at source on the head:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Landing: six of the seven required contexts are green on this head and Generated by Claude Code |
…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>
Fixes #20725
Clause-②: yes (narrowing)
ADR-0126 §7.3 refuses one state: a packaged flow armed while a packaged flow it calls (the
flowNameof asubflowormapnode) is disabled, so the caller fails at that node on the child's refusal.toggleFlowenforced 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 note5900362155).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 packagedsubflow/maptarget is disabled. It reads that throughdisabledPackagedSubflows, the helperrefuseEnableOntoDisabledSubflowuses. There is no second reading of "A calls B"./_statusreportsenabled: true, bound: falsewith areasonnaming each subflow and its remedy. Thekernel:bootstrappedaudit prints the same reason, never "binding failed". The engine warns once per decline.kernel:readytrigger registration and the enable toggle all cross this gate.registerFlow,toggleFlowand hydration all do this. So enabling the subflow arms a declined caller. Republishing the subflowobsolete, or the ledger switching it off at boot, disarms an armed caller.disabledPackagedSubflowsnow 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, theDELETE /api/v1/automation/:namedoor). 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.refuseDisableUnderReachingCallersbecamerefuseUnderReachingCallers(name, act, …), and its disable text is byte-identical.IAutomationService.unregisterFlowcontract, 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.The complete read (
suspended-run-store.ts,engine.ts).SuspendedRunStoregains an optionallistByFlow(flowNames), complete by contract or an error.ObjectStoreSuspendedRunStore.listByFlowseek-walks (keysetWalk,idorder, nooffset) the(flow_name, status)index thatsys_automation_rundeclares. It reads to the end and throws if the walk cannot advance.readSuspendedRunstakes an optional scope, andparkedRunsOfasks for the named callers only. A store without the member is read throughlist(), whose contract is "all".listSuspendedRunsDurable) and its cap are unchanged. The cap was not raised.The artifact reload (
plugin.ts, one call site, a declared deviation).resyncFlowsFromProtocolnow removes vanished flows through a newAutomationEngine.withdrawFlow, which is not guarded. See D3 below for why.The dispatch's mechanism assumptions, measured (tree
01e78dce, then this branch)kernel:readyprotocol sync, themetadata:reloadedresync, andPOST/PUT/ clone) reaches the gate throughregisterFlow. Thekernel:readytrigger reaches it throughregisterTrigger, and the enable toggle throughtoggleFlow.rollbackFlownever arms and has no production caller.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.subflow/maptarget that is notisFlowEnabled, 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.unregisterFlowis: voidin the spec contract. The route calls it withoutawaitand then answers200 { deleted: true }. An async refusal would have been unhandled and answered 200.plugin.ts'smetadata:reloadedresync, which serves package upgrade and uninstall, Studio package publish and dev reload. It is not gated. It removes throughwithdrawFlow, 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.list()reads{ status: 'paused' }withlimit: 1000and 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.Clause-②: yes (narrowing),minor, BREAKING, ADR-0087not-required (no-migration-prescription). The claim readno (narrowing), so this is a deviation.yes: the public surface widens. There is a new publicAutomationEngine.withdrawFlow, a new publicObjectStoreSuspendedRunStore.listByFlow, and a new optional memberSuspendedRunStore.listByFlow.unregisterFlow/DELETErefuses what it accepted, and the disable refusal now sees a parked run it missed past the cap.Live, on a showcase boot (
pnpm dev -- --fresh, built at3fa3860a)DELETE /api/v1/automation/showcase_notify_owneranswered409{code: DELETE_RESTRICTED, httpStatus: 409}, namingshowcase_task_done_notify_owner, and the flow was still served.DELETEthen gave409, "Switch 'showcase_notify_owner' off first". Toggling the subflow off gave 200, andDELETEgave200 {deleted: true}.PUTofshowcase_project_closurewith statusobsolete, then togglingshowcase_closure_signoffoff (200), thenPUTof the caller with statusactive./_statusreadenabled: true, bound: falsewith the reason, and the server log carried one warning naming the subflow.showcase_closure_signoffon then read the callerbound: true.Tests (every reading at
3dc488eb, the head)c416228dheld 21 pins; against the unfixed engine, 16 failed for the intended reason and the 5 controls passed. The fix is3fa3860a.230ef858added two assertions after the fix: once-per-decline, andlistByFlowrefusing to answer short. Each is red at base by construction, and each is turned red by its own ablation leg.subflow-guard-every-door.test.ts(new sibling; the store double gains$and,$gtand one ascendingorderBy, refusing everything else) andsuspended-run-store.test.ts.codeandstatus.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.--listFilescounts both pin files intsconfig.jsonand intsconfig.test.json.scripts/ablation-replace.mjsin wrap mode, with an outer trap onEXIT/INT/TERMrestoring 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.jsrelatively./_statusreason dropped, 3 red.Gates (at
3dc488eb)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackgives 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".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-levelandpnpm check:startup-registry-verdict.check-changeset-no-majorandcheck-adr-0087-registrationwere run against a syntheticpull_requestpayload carrying this body; both exit 0.eslint --no-inline-config --format jsonover the 6 changed files gave 0 errors and 1 warning (the changeset.mdhas no matching config). The population comes fromeslint.config.mjsfiles**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}. The config enables no type-aware linting (noparserOptions.project), so the diff cannot move a verdict on an untouched file.dispatch-gatesmarks as not runnable locally, because their argv comes from the workflow.Changeset
.changeset/20725-subflow-guard-every-door.md:@objectstack/service-automationminor, BREAKING, with theClause-②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
securitycard, with no request detail anywhere public. It is not fixed here.ObjectStoreSuspendedRunStore.list()still reads one capped page of 1000pausedrows. 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.origin/mainhas moved one commit (697845d1, a service-package citation re-anchor and its changeset). It does not touchservice-automationor the automation route, and it is not merged here. The merge queue validates the merge ref.Generated by Claude Code