fix(service-automation)!: the toggle door switches packaged flows only; a customer flow is refused, naming its status switch (#20726) - #20780
Conversation
…first) Three pins beside the activation-ledger suite, read off the door's outputs: a customer-authored flow toggled through the door is refused with RESOURCE_CONFLICT / 409 naming its status switch, and neither the ledger nor the flow moves (three provenance shapes, both directions, and the no-ledger degraded mode); a packaged flow still toggles (the control); and a customer flow published with status 'obsolete' is not armed (the switch the refusal names works). Red against the unfixed engine by design: the fix follows. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
…hips, naming its status switch toggleFlow records an installation's choice about a PACKAGED flow in the activation ledger (ADR-0126 §4, §7.2). For a flow authored in the deployment it wrote a row anyway: with no package id the durable store refused it with a validation error naming a field the caller never sent, and with a sentinel or app package id it recorded a second off-switch for a flow whose switch is its own status. Now, first and ahead of both §7.3 guards, a flow whose provenance is not 'package' (describeFlowContender, the discriminator the §7.3 guards ask) is refused with RESOURCE_CONFLICT / 409 in either direction, before any ledger write and before any in-process change, with or without a ledger attached. The refusal says the door switches packaged flows and names the flow's own switch: its status, published through PUT /automation/NAME. The door never rewrites the definition. Fixture triage: ten existing cases toggled a flow with no package envelope only as a vehicle for toggle semantics; their subject now ships from a package. The two §7.3 non-packaged pins are re-spelled through the status switch: a customer subflow is switched off by its status, and a customer caller is refused by the door and armed by its status. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
…r row already holds off (red first) The door used to accept a customer flow whose package id was non-empty (the sys_metadata sentinel, an app-bound tenant row) and wrote a ledger row for it. After the refusal, such a flow is held off by a row its status does not clear, so a refusal naming only the status prescribes a step that completes nothing. The pin asserts the refusal still writes nothing and names the step that does complete (a clone under a new name), and shows that step arms the copy. Red against the previous commit by design: the message branch follows. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
…a customer flow a ledger row holds off A ledger row can already stand under a customer flow's name (written by this door before it refused customer-authored flows, or by a packaged flow the customer overlay shadows), and a status does not clear it. For that flow the refusal no longer stops at the status switch: it says the row holds the flow off and names the step the FLOW_DISABLED refusal already names for a ledger-held flow, a clone under a new name. Still nothing is written. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
… customer flow's switch is its status Three published lines said the toggle door enables or disables "a flow". It switches packaged flows only, and a flow authored in the deployment is refused with 409 RESOURCE_CONFLICT. Each line now says which flows the door switches and what a customer flow uses instead: its status, 'obsolete' or 'active', published with the complete definition through PUT /automation/:name. - the Automation API module docblock (the source of the generated API reference page), prose only; - client.automation.toggle, whose docblock had drifted above an unrelated member and is moved back onto toggle; - the automation domain's route list and authoring-write predicate list. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Output of `pnpm --filter @objectstack/spec check:generated --fix`, which found exactly one stale artifact (content/docs/references/**) and regenerated it with gen:docs from a spec dist it built. Not hand-edited. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
…e package README stops toggling a customer flow One changeset per package whose published bytes move, measured on the built output: service-automation (behaviour, minor, BREAKING, with its migration), spec (the docblock ships in src/**/*.zod.ts) and client (the docblock reaches dist/index.d.ts). The runtime route docblocks reach no published file, so runtime has none. The service-automation README (published) registered a flow in process and then toggled it off, the exact call the door now refuses. It now switches that flow off through its status, and shows the toggle on a packaged flow. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
The pending note for the enable guard lists "a flow the customer authored" under "Not refused". In the release that ships it, the activation switch refuses a customer-authored flow before that guard is asked. The sentence is corrected in its own entry rather than by an erratum elsewhere; the customer-authored subflow half stays true and stays. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
…(red first) The pin asserted the notice contains 'toggle', which pinned the prescription itself: switch the clone off through the activation toggle. A clone carries no package envelope, and that switch refuses a flow no package ships. The pin now asserts the notice names the switch the clone has: its status, 'obsolete', through PUT /api/v1/automation/NAME. Red against the current notice by design: the notice follows. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
…h, its status The notice every successful clone answers told the admin to switch the clone off through the activation toggle. A clone carries no package envelope, so the toggle, which switches packaged flows only, refuses it: the notice prescribed a step the platform refuses. It now names the clone's own switch, status 'obsolete' through PUT /api/v1/automation/NAME with the complete definition, and says the toggle is for packaged flows such as the one the clone was copied from. Measured at the dispatcher seam with the real engine: the clone door answered 200 with the old notice, the clone carried no _packageId, and the toggle it named answered RESOURCE_CONFLICT / 409 for it. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
…es packaged flows The service contract's docblock read "Enable or disable a flow", the same published line the API page carried. It now says the switch records the installation's choice for packaged flows, that a flow authored in the deployment is refused with RESOURCE_CONFLICT / 409, and that such a flow's switch is its own status, published through registerFlow. Prose only. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
📓 Docs Drift CheckThis PR changes 4 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 144 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 ed9aa7da373c40c312bc32ca69c9699849d7b5c0 && git checkout ed9aa7da373c40c312bc32ca69c9699849d7b5c0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 085ca6bc1c446e6484713823ce92284b4ec0ad54 843341912c6e7744d0d0d1f71b81011fcbf64c3c && git checkout -B drift-repro 085ca6bc1c446e6484713823ce92284b4ec0ad54 && git merge --no-ff 843341912c6e7744d0d0d1f71b81011fcbf64c3c
node scripts/docs-audit/affected-docs.mjs --json 085ca6bc1c446e6484713823ce92284b4ec0ad54
|
Contract reviewServed-tier: Inputs: card #20726 (body; triage direction Checks on the head, read at 2026-09-30T05:31Z: 35 check-runs, one per name, none in progress. 32 ① Derived judgmentsRead against triage's option 1: refuse, don't write; name the customer flow's own switch; the door never rewrites a definition; packaged flows unchanged; docs say which flows the door switches.
② Semver level
③ Boundary flagsDev flags and
Implemented-by: VERDICT: FAIL One published sentence to narrow (③ Q3(b)); everything else on this head — option 1 as ruled, the corrected release note sentence by sentence, the semver declaration and the four changesets' levels — is confirmed, so the record on the corrected head should be short. Generated by Claude Code |
The clone door takes any registered flow as its source, with no provenance test, so "the toggle switches packaged flows only, such as the one it was copied from" is false when a customer-authored flow is cloned. The notice, its docblock and the runtime changeset now say only what holds for every clone: the toggle switches packaged flows only and refuses the clone. The prescription is unchanged: switch the clone off through its own status, via PUT /api/v1/automation/NAME. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Contract reviewServed-tier: A re-record on a new head. Record Inputs: card #20726 (body; triage direction The delta d7eb865..8433419, verified: ONE commit, 2 files, +5 / −4: Checks on the head, read at 2026-09-30T06:15Z: 42 check-runs over 35 names, none in progress; seven names ran twice because the ① Derived judgmentsRead against triage's option 1 ( Carried forward from record
10. The clone-notice rider (
15. The DELIBERATE CORRECTION,
② Semver level
③ Boundary flagsDev flags and
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Landing with one red check, by design · The gate:
The reason: this PR corrects
The confirmation: the at-tier contract review PASS on this same head,
The three conditions for landing with it red, each met:
Also recorded: that job stops at this step. So neither the ADR-0087 disposition step nor the launch-window
Generated by Claude Code |
Fixes #20726
Clause-②: no (narrowing)
What this does
toggleFlowis the automation service's activation switch. It is served asPOST /api/v1/automation/NAME/toggleand called byclient.automation.toggle. It now switches packaged flows only, as triage's direction on this card reads ADR-0126 §4 and §7.2 (option 1, comment5901337034).RESOURCE_CONFLICT/409. That holds in both directions, and with or without an activation ledger attached. The refusal is the first thingtoggleFlowdoes after the unknown-flow check. It runs ahead of both ADR-0126 §7.3 guards, the ledger write and any in-process change, so nothing half-flips.status, published through its update door,PUT /automation/NAME, which takes the complete definition.obsoleteswitches it off andactivearms it.statusitself.The refusal as the wire carries it, measured at the dispatcher seam (
HttpDispatcher.handleAutomation, the real engine, a flow with no package envelope):The transport maps that thrown shape (
err.status,err.code) to the HTTP answer inerrorResponseBase. #20678's disable-half dev measured this live, on this door, for the sameObject.assign(new Error(…), { code, status })shape.The code:
RESOURCE_CONFLICT/ 409 (G3)Chosen from the standard catalog (
StandardErrorCode). No ledger entry is minted, andpnpm check:error-code-casingis green.RESOURCE_CONFLICT/ 409 for its other state conflict, the §7.3 enable guard. The door keeps one dialect.VALIDATION_ERROR): nothing in the request is malformed, and today's 400 is the defect;DELETE_RESTRICTED: that member means dependencies;METHOD_NOT_ALLOWED: the route serves the method.A customer flow that a ledger row already holds off
The ledger is keyed by name, so a row can already stand under a customer flow's name, in two ways:
sys_metadataruntime-row sentinel, or a tenant-authored row bound to an app package. Pin 1 at base shows both were accepted, and it wrote a row for each.A
statusdoes not clear such a row:isFlowEnabledcomposes the two, and neither overrides the other. Measured at the engine seam:enabled: false.activeleaves itenabled: false.So for that flow alone, a refusal that stopped at "publish it
active" would name a step that completes nothing. In that state the refusal says the row holds the flow off. It names the step that does complete, which theFLOW_DISABLEDrefusal already names for a ledger-held flow: clone it under a new name (POST /automation/NAME/clone), which arms the copy, then remove the old one. Still nothing is written. Whether the door should instead clear such a row is not this refusal's to decide. It is the first open question in the dev report.Measured premises (G1 to G7)
describeFlowContender(flow).source !== 'package'(isCodeArtifactBody), the discriminator the §7.3 guards already ask. No second reading was added.sys_metadatasentinel or a tenant-authored app-bound row carries a non-empty_packageId, so the ledger's "Package is required" never refused it. At base it toggled and wrote a row.toggleFlowwrote the ledger row first, withpackageId: String(flow._packageId ?? ''). The refusal sits before that write and before the in-process change.flowActivationStore): at base a customer flow flipped in process only, with theIN PROCESS ONLYwarning. Now it is refused identically, nothing moves, and the warning is never reached (pin 1, degraded case).PUT /:nameinpackages/runtime/src/domains/automation.ts(updateFlow). It callsregisterFlow(name, definition)with the complete definition. The refusal names it as 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 refusals name doors (PUT /automation/NAME). Pin 3 drives that registration path.registerFlow, and the engine persists no definition. A flow whose definition lives on the metadata plane re-registers its stored definition at the next boot or metadata reload. Dev report, open question 2..mdxwas regenerated, not hand-edited.Clause-②: no (narrowing), measured on this diff. It is not copied from the claim.VALIDATION_FAILEDbecomes 409RESOURCE_CONFLICT.no (narrowing), and the service-automation changeset isminorwith a BREAKING banner.Pins: red first, and an ablation for each
New file
packages/services/service-automation/src/toggle-door-packaged-only.test.ts, beside the ledger suite.Pin 1. A customer-authored flow toggled through the door gets the named refusal, and the ledger and the flow are unchanged. It asserts:
codeandstatus;status, andPUT /automation/NAME;setActivenever called and the ledger rows unchanged;/_statusstate unchanged, the trigger still bound, andexecutestill running.Cases: three provenance shapes × both directions, the no-ledger degraded mode, and a customer flow that a ledger row already holds off.
Pin 2 (the control). A packaged flow still toggles: row written, disarmed,
FLOW_DISABLED, then re-enabled and re-armed.Pin 3. A customer flow published with
status: 'obsolete'through the registration path is not armed, andactivearms it again. The ledger is never written.Order of commits:
9c9eb7b6cpins red:Tests 7 failed | 2 passed (9). Each failure read "expected the toggle door to refuse, and it accepted".7b35222ecthe fix.c11a4f1a6the held-off pin, red:1 failed | 9 passed (10), on the missing clone step.94a4e3af6its message branch.Ablations:
scripts/ablation-replace.mjswrap mode, run from the committed state, with an outertrapon EXIT/INT/TERM restoring by absolute path. In every leg the anchor hit 1 → 0, the blob changed, and the restore read "blob == HEAD andgit diff HEADis empty". There is no dist leg: the pins import./engine.jsrelatively, so they resolvesrc.obsoleteno longer disablesDocs: three lanes, prose only (G5)
packages/spec/src/api/automation-api.zod.tsmodule docblock.content/docs/references/api/automation-api.mdxcomes frompnpm --filter @objectstack/spec check:generated --fix: exactly one stale artifact, regenerated from a dist that run built.client.automation.toggle. Its one-line docblock had drifted above an unrelated member (listActions). It is moved back ontotoggleand says which flows the door switches.packages/runtime/src/domains/automation.ts: the route list onhandleAutomationRequestand the authoring-write predicate's list.content/docs/releases/**is untouched.Changesets, one per package whose published bytes move (G6)
Each marker was grepped over the package's
files[]after a build, with a control phrase from the same file.@objectstack/service-automationminor, BREAKINGdist/index.jsanddist/index.cjs(2 files; control 2)@objectstack/specpatchsrc/api/automation-api.zod.ts, shipped byfiles[](src/**/*.zod.ts); the contract docblock is indist/contracts/index.d.tsand.d.mts(control 2)@objectstack/clientpatchdist/index.d.ts,.d.mts,index.jsandindex.mjs(4; control 4)@objectstack/runtimepatchFlow definition body required) hitsdist/index.jsanddist/index.cjs; the maps carry nosourcesContentADR-0087 disposition on the breaking changeset:
not-required (no-migration-prescription). No metadata changes shape and nothing an author wrote is renamed or removed. Its body carries the migration:POST …/NAME/toggleon a customer flow TOPUT /api/v1/automation/NAMEwithstatus: 'obsolete'or'active';client.automation.toggle(name, false)TOclient.automation.update(name, { ...definition, status: 'obsolete' }).Outside the claim's declared file surface: deviations, each for a named reason
The claim declared
engine.ts(toggleFlowonly), a pin file, the three docs lanes and.changeset/20726-*.md. These files moved beyond it, one reason each:A private helper beside
toggleFlow(refuseCustomerAuthoredToggle) holds the refusal, on the pattern ofrefuseEnableOntoDisabledSubflow.toggleFlowis its only caller.Fixture triage (necessary to stay green). Ten existing cases toggled a flow with no package envelope, only as a vehicle for toggle semantics. Their subject now ships from a package (
_packageId: 'crm'):engine.test.ts×6;engine-residual-log-cause,flow-label-on-result,flow-terminal-messagesandnode-type-vocabulary-seal-warning×1 each.The two §7.3 non-packaged pins in
flow-activation-ledger.test.tspinned toggling a customer flow. They are re-spelled through its status: a customer subflow is switched off by its status, and a customer caller is refused by the door and armed by its status.packages/services/service-automation/README.md(published). Its example registered a flow in process and then toggled it off, which is exactly the call this change refuses. It now switches that flow off through its status and shows the toggle on a packaged flow.A DELIBERATE CORRECTION of a pending release note,
.changeset/20678-subflow-disable-sequence.md, for confirmation on this PR.node scripts/check-empty-changeset.mjsis red on it by design (its DELIBERATE CORRECTION class). ⛔ Do not restore it from base: that would republish the false sentence.The clone door's notice (
packages/runtime/src/flow-clone.ts,FLOW_CLONE_NOTICE, plus its pin inautomation-flow-clone.test.tsand a runtimepatchchangeset)._packageId, and the named toggle answeredRESOURCE_CONFLICT/ 409.PUT), and says the toggle switches packaged flows only and refuses the clone, whatever the clone was copied from: the clone door takes any registered flow as its source.toggle, which pinned the prescription. It now assertsstatus: 'obsolete'andPUT /api/v1/automation/(red against the old notice, ablation M5).IAutomationService.toggleFlow's docblock (packages/spec/src/contracts/automation-service.ts, published indist/contracts/*.d.ts) read "Enable or disable a flow", the same line as the API page. It now says the same as the other lanes. Prose only.Local verification at
d7eb865aaEvery reading below was taken on this tree at
d7eb865aa, the head this PR opens with. The exit code was captured before any pipe.Package suites. Each package this diff touches got its test and typecheck (
pnpm --filter PKG):service-automationTest Files 157 passed (157),Tests 1974 passed (1974); base0d9349fearead 156 / 1964--listFilescounts the new pin file once intsconfig.jsonand once intsconfig.test.jsonspec576 passed (576),16991 passed, 1 todoclient50 passed (50),641 passed (641)runtime290 passed (290),4200 passed, 1 skippeddogfoodautomation-toggle-tenant-scopeandpackaged-activation-ledger-reach:2 passed,19 passedDerived gates.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived on the real diff: 111 commands. All 111 ran, and--ranreads: "111 derived famil(ies) accounted for — 111 run, 0 NOT-MEASURED (a DERIVED zero — all 111 recorded an exit code and none of them is 3)".check:skill-examples,check:dual-build-cjs-loadsandcheck:type-check-debt. They exit 0 after a full packages build (turbo run build, 71/71 tasks).node scripts/check-empty-changeset.mjs --base origin/main. That is its DELIBERATE CORRECTION class on.changeset/20678-subflow-disable-sequence.md(deviation 4, for confirmation).The seven roster families printed outside the runnable list, each exit 0:
node scripts/check-changeset-fixed.mjspnpm --filter @objectstack/spec run check:meta-url-spellingpnpm --filter @objectstack/spec run check:spec-changespnpm check:authz-resolverpnpm check:error-code-casing("no unlisted lowercase error codes in 7012 scanned file(s)")pnpm check:filter-alias-paritypnpm check:route-ledger-censusExtra, each exit 0:
pnpm check:durability-log-levelpnpm check:startup-registry-verdictnode scripts/check-changeset-no-major.mjs --base origin/main --eventwith a syntheticpull_requestpayload carrying this body: "LEVEL AXIS: this PR declares clause-②no (narrowing), and it grades a package whosepackages/**/src/**it moves atminoror above"check-adr-0087-registrationwith the same payload: 1 declared-breaking changeset,not-required (no-migration-prescription)Generated artifacts.
pnpm --filter @objectstack/spec check:generated: all 15 up to date, after one--fixofcontent/docs/references/**.Lint, a declared narrowing.
eslint --no-inline-config --format jsonover the 21 changed files: 21 files, 0 errors, 7 warnings. Every warning is "File ignored because no matching configuration was supplied", on the.md/.mdxfiles.eslint.config.mjs'sfiles: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'].parserOptions.projectand 0 hits forprojectService. So this diff cannot move a verdict on an untouched file.Bytes.
pnpm check:nul-bytesexit 0, and a control-byte self-scan of the 21 files found nothing.NOT MEASURED, and why:
check-issue-citations --census, threecheck-shard-attestation --emitand twocheck-test-completeness.Dogfood Regression Gateruns it.Acceptance notes
PUT /automation/NAMEpersists no definition. A flow whose definition lives on the metadata plane re-registers its stored definition at the next boot or metadata reload. The dev report's open question 2. Noted, not filed.POST /automation/:name/toggleis out of date (packages/runtime/src/route-ledger.ts). It still describestoggleFlowas writing "an in-process map keyed by flow name only", which has been untrue since the activation ledger landed, and it does not say "packaged only". Outside this PR's declared surface. Noted, not filed.refuseEnableOntoDisabledSubflow's early return for a non-packaged flow is now unreachable throughtoggleFlow, its only caller. It is harmless, and left in place. Noted, not filed.client.automation.togglestays as published (content/docs/releases/**is release-owned).Patch round 1 (appended by the
domain:servicesseat)d7eb865a(record5904799342) FAILed on one clause. The clone-notice rider asserted that the clone's source is packaged ("such as the one it was copied from"), butPOST /:name/clonetakes any registered flow as its source.84334191, one fast-forward commit (2 files, +5 / −4). The claim about the source is dropped fromFLOW_CLONE_NOTICE, its docblock and.changeset/20726-clone-notice-status-switch.md. The prescription (the clone's ownstatus, throughPUT /api/v1/automation/NAME) is unchanged.84334191:@objectstack/runtime: 4200 passed, 1 skipped; typecheck exit 0;dispatch-gates --commands: the same 111 as round 1, all run. 110 exit 0 (check:doc-authoringamong them), and the one exit 1 ischeck-empty-changeseton the confirmed DELIBERATE CORRECTION of the 20678 note, unchanged.84334191before enqueue.Generated by Claude Code