From 9c9eb7b6cdcc038ca3d075a58fb81535a3d30eaa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 03:48:48 +0000 Subject: [PATCH 01/12] test(service-automation): pin the toggle door to packaged flows (red 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 Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H --- .../src/toggle-door-packaged-only.test.ts | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 packages/services/service-automation/src/toggle-door-packaged-only.test.ts diff --git a/packages/services/service-automation/src/toggle-door-packaged-only.test.ts b/packages/services/service-automation/src/toggle-door-packaged-only.test.ts new file mode 100644 index 00000000000..4bbf02c1f1d --- /dev/null +++ b/packages/services/service-automation/src/toggle-door-packaged-only.test.ts @@ -0,0 +1,213 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#20726] ADR-0126 §7.2 — the toggle door switches PACKAGED flows, and only +// them. +// +// The door (`toggleFlow`, served as `POST /automation/:name/toggle`) records an +// installation's choice in the packaged-metadata activation ledger, +// `sys_metadata_activation`, whose rows each name "the package that ships the +// base artifact". A flow authored in the deployment has no such package, and +// it already has its own off-switch: its definition's `status` (`obsolete` +// disarms, `active` arms), published through its update door. So the door +// refuses a customer-authored flow loudly, BEFORE any ledger write and before +// any in-process change, and names that switch — in both directions, with a +// ledger attached or without one. ⛔ It never rewrites the definition itself: +// that would make it a second write door into definitions. +// +// Three pins, read off the door's own outputs — the refusal envelope, the +// ledger rows, the trigger binding and the `/_status` row — never off the +// guard's body: +// 1. a customer-authored flow toggled through the door gets the named +// refusal, and the ledger and the flow are unchanged; +// 2. a packaged flow still toggles (the control); +// 3. a customer flow published with `status: 'obsolete'` is not armed — the +// switch the refusal names works. + +import { describe, it, expect, vi } from 'vitest'; +import { AutomationEngine } from './engine.js'; +import type { FlowTrigger, FlowTriggerBinding } from './engine.js'; +import { InMemoryFlowActivationStore } from './flow-activation-store.js'; +import type { AutomationContext } from '@objectstack/spec/contracts'; + +function createTestLogger(): any { + const l: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + l.child = () => l; + return l; +} + +/** A recording trigger: binding state is read off the trigger, never inferred. */ +function recordingTrigger() { + const bound = new Map Promise>(); + const trigger: FlowTrigger = { + type: 'record_change', + start(binding: FlowTriggerBinding, cb: (ctx: AutomationContext) => Promise) { + bound.set(binding.flowName, cb); + }, + stop(flowName: string) { + bound.delete(flowName); + }, + }; + return { trigger, isBound: (n: string) => bound.has(n) }; +} + +/** A record-triggered flow, so whether it is armed shows on the trigger. */ +function flowBody(name: string, extra: Record = {}) { + return { + name, + label: name, + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'lead', triggerType: 'record-after-create' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + ...extra, + }; +} + +/** Shipped by a code package (ADR-0029 D9.6 provenance). */ +const packaged = (name: string, extra: Record = {}) => flowBody(name, { ...extra, _packageId: 'crm' }); + +/** + * The three shapes a flow authored in this deployment reaches the engine in — + * none of them package provenance by the canonical test (`isCodeArtifactBody`, + * which the §7.3 guards already ask through `describeFlowContender`): + * - no package envelope at all (the automation create door, the clone door); + * - the `sys_metadata` sentinel a runtime-authored row is registered under; + * - a tenant-authored row bound to an app package (`_provenance: 'org'`). + * The second and third carry a non-empty `_packageId`, so the ledger's + * "Package is required" never refused them: they are the proof that the + * refusal reads provenance, not the emptiness of a package id. + */ +const CUSTOMER_SHAPES: Array<[string, Record]> = [ + ['no package envelope', {}], + ["the 'sys_metadata' runtime-row sentinel", { _packageId: 'sys_metadata' }], + ['a tenant-authored row bound to an app package', { _packageId: 'app.crm', _provenance: 'org' }], +]; + +/** The row `GET /automation/_status` serves for one flow. */ +function stateOf(engine: AutomationEngine, name: string) { + return engine.getFlowRuntimeStates().find((s) => s.name === name); +} + +/** An engine with the in-memory ledger and a record trigger attached. */ +function engineWithLedger() { + const logger = createTestLogger(); + const engine = new AutomationEngine(logger); + const store = new InMemoryFlowActivationStore(); + engine.setFlowActivationStore(store); + const records = recordingTrigger(); + engine.registerTrigger(records.trigger); + return { engine, store, records, logger }; +} + +/** The refusal, caught, so its envelope can be read. */ +async function refusalOf(p: Promise): Promise { + try { + await p; + } catch (e) { + return e as Error & { code?: unknown; status?: unknown }; + } + throw new Error('expected the toggle door to refuse, and it accepted'); +} + +describe('[#20726] pin 1 — the toggle door refuses a customer-authored flow, and changes nothing', () => { + for (const [shape, envelope] of CUSTOMER_SHAPES) { + for (const enabled of [false, true]) { + it(`${shape}, enabled: ${enabled} — RESOURCE_CONFLICT / 409 naming its status switch; the ledger and the flow are unchanged`, async () => { + const { engine, store, records } = engineWithLedger(); + engine.registerFlow('customer_flow', flowBody('customer_flow', envelope)); + const setActive = vi.spyOn(store, 'setActive'); + const before = stateOf(engine, 'customer_flow'); + expect(before).toMatchObject({ enabled: true, bound: true }); + + const refusal = await refusalOf(engine.toggleFlow('customer_flow', enabled)); + + // ADR-0112 envelope: code AND status. + expect(refusal.code).toBe('RESOURCE_CONFLICT'); + expect(refusal.status).toBe(409); + // The named subjects: what the door switches, and the flow's + // own switch — its status, through its update door. + expect(refusal.message).toMatch(/packaged flows/); + expect(refusal.message).toMatch(/status/); + expect(refusal.message).toContain('PUT /automation/customer_flow'); + + // Nothing was written, and nothing moved in process. + expect(setActive).not.toHaveBeenCalled(); + expect(await store.list()).toEqual([]); + expect(stateOf(engine, 'customer_flow')).toEqual(before); + expect(records.isBound('customer_flow')).toBe(true); + expect((await engine.execute('customer_flow')).success).toBe(true); + }); + } + } + + it('with NO ledger attached (the degraded mode) the refusal is the same, and nothing flips in process', async () => { + const logger = createTestLogger(); + const engine = new AutomationEngine(logger); + const records = recordingTrigger(); + engine.registerTrigger(records.trigger); + engine.registerFlow('customer_flow', flowBody('customer_flow')); + const before = stateOf(engine, 'customer_flow'); + + const refusal = await refusalOf(engine.toggleFlow('customer_flow', false)); + + expect(refusal.code).toBe('RESOURCE_CONFLICT'); + expect(refusal.status).toBe(409); + expect(stateOf(engine, 'customer_flow')).toEqual(before); + expect(records.isBound('customer_flow')).toBe(true); + expect((await engine.execute('customer_flow')).success).toBe(true); + // The degraded-mode "IN PROCESS ONLY" flip was never reached. + const warned = logger.warn.mock.calls.map((c: unknown[]) => String(c[0])); + expect(warned.some((m: string) => m.includes('IN PROCESS ONLY'))).toBe(false); + }); +}); + +describe('[#20726] pin 2 — a packaged flow still toggles (the control)', () => { + it('disable writes its ledger row and disarms it; enable updates the row and re-arms it', async () => { + const { engine, store, records } = engineWithLedger(); + engine.registerFlow('shipped_flow', packaged('shipped_flow')); + expect(records.isBound('shipped_flow')).toBe(true); + + await engine.toggleFlow('shipped_flow', false); + + expect(await store.list()).toEqual([{ name: 'shipped_flow', packageId: 'crm', active: false }]); + expect(records.isBound('shipped_flow')).toBe(false); + expect(stateOf(engine, 'shipped_flow')).toMatchObject({ enabled: false, bound: false }); + const refused = await engine.execute('shipped_flow'); + expect(refused.success).toBe(false); + expect((refused as { code?: string }).code).toBe('FLOW_DISABLED'); + + await engine.toggleFlow('shipped_flow', true); + + expect(await store.list()).toEqual([{ name: 'shipped_flow', packageId: 'crm', active: true }]); + expect(records.isBound('shipped_flow')).toBe(true); + expect(stateOf(engine, 'shipped_flow')).toMatchObject({ enabled: true, bound: true }); + }); +}); + +describe("[#20726] pin 3 — the switch the refusal names works: a customer flow published with status 'obsolete' is not armed", () => { + it("publishing 'obsolete' through the registration path disarms it, and 'active' arms it again — the ledger is never written", async () => { + const { engine, store, records } = engineWithLedger(); + const setActive = vi.spyOn(store, 'setActive'); + // `PUT /automation/:name` drives exactly this: `registerFlow(name, definition)`. + engine.registerFlow('customer_flow', flowBody('customer_flow', { status: 'active' })); + expect(records.isBound('customer_flow')).toBe(true); + + engine.registerFlow('customer_flow', flowBody('customer_flow', { status: 'obsolete' })); + + expect(records.isBound('customer_flow')).toBe(false); + expect(stateOf(engine, 'customer_flow')).toMatchObject({ enabled: false, bound: false, status: 'obsolete' }); + const refused = await engine.execute('customer_flow'); + expect(refused.success).toBe(false); + expect((refused as { code?: string }).code).toBe('FLOW_DISABLED'); + + engine.registerFlow('customer_flow', flowBody('customer_flow', { status: 'active' })); + + expect(records.isBound('customer_flow')).toBe(true); + expect(stateOf(engine, 'customer_flow')).toMatchObject({ enabled: true, bound: true, status: 'active' }); + expect((await engine.execute('customer_flow')).success).toBe(true); + expect(setActive).not.toHaveBeenCalled(); + expect(await store.list()).toEqual([]); + }); +}); From 7b35222ecaf96eed4fc8455946e81a13da2cad94 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 03:59:58 +0000 Subject: [PATCH 02/12] fix(service-automation)!: the toggle door refuses a flow no package ships, naming its status switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H --- .../src/engine-residual-log-cause.test.ts | 3 +- .../service-automation/src/engine.test.ts | 24 ++++-- .../services/service-automation/src/engine.ts | 83 +++++++++++++++++-- .../src/flow-activation-ledger.test.ts | 26 ++++-- .../src/flow-label-on-result.test.ts | 3 +- .../src/flow-terminal-messages.test.ts | 3 +- .../node-type-vocabulary-seal-warning.test.ts | 3 +- 7 files changed, 120 insertions(+), 25 deletions(-) diff --git a/packages/services/service-automation/src/engine-residual-log-cause.test.ts b/packages/services/service-automation/src/engine-residual-log-cause.test.ts index f6b939d2ed9..95e32483600 100644 --- a/packages/services/service-automation/src/engine-residual-log-cause.test.ts +++ b/packages/services/service-automation/src/engine-residual-log-cause.test.ts @@ -424,7 +424,8 @@ describe('#6499 sites 5–8 — plugin-supplied code seams, all #4632 FUNCTIONAL const engine = new AutomationEngine(jsonLogger()); const trigger: FlowTrigger = { type: 'record_change', start() {}, stop() { throw new Error(MULTILINE_DRIVER); } }; engine.registerTrigger(trigger); - engine.registerFlow('rc_unbind', triggeredFlow('rc_unbind')); + // [#20726] Unbound through the toggle, which switches packaged flows only. + engine.registerFlow('rc_unbind', { ...triggeredFlow('rc_unbind'), _packageId: 'crm' }); const streams = await captureBoth(async () => { await engine.toggleFlow('rc_unbind', false); }); diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index c272aa4f4f4..5b92d3af822 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -1334,9 +1334,16 @@ describe('AutomationEngine - Execution History', () => { }); }); + /** + * [#20726, ADR-0126 §7.2] The toggle switches PACKAGED flows — a flow no + * package ships is refused, and its switch is its `status` — so every + * subject toggled below ships from a code package. + */ + const packagedSimpleFlow = { ...simpleFlow, _packageId: 'crm' }; + describe('toggleFlow', () => { it('should disable a flow', async () => { - engine.registerFlow('test_flow', simpleFlow); + engine.registerFlow('test_flow', packagedSimpleFlow); await engine.toggleFlow('test_flow', false); const result = await engine.execute('test_flow'); @@ -1345,7 +1352,7 @@ describe('AutomationEngine - Execution History', () => { }); it('should enable a disabled flow', async () => { - engine.registerFlow('test_flow', simpleFlow); + engine.registerFlow('test_flow', packagedSimpleFlow); await engine.toggleFlow('test_flow', false); await engine.toggleFlow('test_flow', true); @@ -1485,11 +1492,11 @@ describe('AutomationEngine - Execution History', () => { * wall's stated prohibition. */ it('keeps a ledger disable across unregister + re-register (§6 wall 3)', async () => { - engine.registerFlow('test_flow', simpleFlow); + engine.registerFlow('test_flow', packagedSimpleFlow); await engine.toggleFlow('test_flow', false); engine.unregisterFlow('test_flow'); - engine.registerFlow('test_flow', simpleFlow); + engine.registerFlow('test_flow', packagedSimpleFlow); const result = await engine.execute('test_flow'); expect(result.success).toBe(false); @@ -2920,7 +2927,8 @@ describe('AutomationEngine - Flow Trigger Wiring', () => { it('stops/restarts the binding when the flow is disabled/re-enabled', async () => { const rec = recordingTrigger('record_change'); engine.registerTrigger(rec.trigger); - engine.registerFlow('rc_flow', recordChangeFlow('rc_flow')); + // [#20726] The toggle switches packaged flows only. + engine.registerFlow('rc_flow', { ...recordChangeFlow('rc_flow'), _packageId: 'crm' }); await engine.toggleFlow('rc_flow', false); expect(rec.stopped).toEqual(['rc_flow']); @@ -3227,7 +3235,8 @@ describe('#9378 — execute() classifies terminal exits for the trigger transpor expect(missing.status).toBeUndefined(); // 2. Registered but disabled. - engine.registerFlow('disabled_flow', failingFlow('disabled_flow')); + // [#20726] Disabled through the toggle, which switches packaged flows only. + engine.registerFlow('disabled_flow', { ...failingFlow('disabled_flow'), _packageId: 'crm' }); await engine.toggleFlow('disabled_flow', false); const disabled = await engine.execute('disabled_flow'); expect(disabled.success).toBe(false); @@ -3264,7 +3273,8 @@ describe('#9378 — execute() classifies terminal exits for the trigger transpor * applied to one copy only is the shape a later reader mistakes for a rule. */ it('says WHICH refusal a never-dispatched exit is — code, and still no status', async () => { - engine.registerFlow('disabled_coded', failingFlow('disabled_coded')); + // [#20726] Disabled through the toggle, which switches packaged flows only. + engine.registerFlow('disabled_coded', { ...failingFlow('disabled_coded'), _packageId: 'crm' }); await engine.toggleFlow('disabled_coded', false); const disabled = await engine.execute('disabled_coded'); expect(disabled.success).toBe(false); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 8794236c4bd..1ce5575c85d 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -5135,7 +5135,68 @@ export class AutomationEngine implements IAutomationService { } /** - * [ADR-0126 §7.2] Flip a flow's activation — THE sanctioned off-switch. + * [#20726, ADR-0126 §7.2] Refuse the toggle door for a flow no package + * ships — loudly, naming that flow's own switch, and before anything is + * written or changed. + * + * ## Why the door switches packaged flows only + * + * The door records an installation's CHOICE about a packaged artifact in + * `sys_metadata_activation`, whose rows each name "the package that ships + * the base artifact" (§4), and §7.2 makes that row "the sanctioned + * off-switch for packaged flows". A flow authored in this deployment has + * no such package, and it already has an off-switch of its own: its + * definition's `status` (`obsolete` / `invalid` disarm it, see + * {@link registerFlow}; {@link isFlowEnabled} composes the two). Writing + * it into the ledger anyway would give it a SECOND off-switch under a + * package key its declaration excludes — with an empty id the durable + * store refused the write with a validation error naming a field the + * caller never sent, and with a sentinel id it would record a package + * that ships nothing. + * + * ⛔ The door does not rewrite the definition's `status` itself either: + * that would make it a second write door into definitions. It names the + * flow's update door instead — `PUT /automation/:name`, which drives + * {@link registerFlow} with the complete definition. + * + * "No package provenance" is `describeFlowContender(flow).source !== + * 'package'` — the one discriminator the §7.3 guards already ask + * (`isCodeArtifactBody`, ADR-0029 D9.6), ⛔ never a second reading. So a + * runtime row carrying the `sys_metadata` sentinel, and a tenant-authored + * row bound to an app package, are refused exactly as a flow with no + * package envelope at all is: provenance decides, not whether a package + * id happens to be non-empty. + * + * ADR-0112 envelope: code AND status. `RESOURCE_CONFLICT` (409) is the + * standard catalog's "the request conflicts with the resource's current + * state" member — the flow exists and the request is well-formed, but the + * target's provenance does not admit the act — and it is the code this + * same door already answers for its other state conflict (the §7.3 + * enable guard), so the door speaks one dialect. ⛔ No new ledger entry is + * minted. Not a 400: nothing about the request is malformed. Not a 403: + * no caller could be authorized into it. Not `DELETE_RESTRICTED`: that + * member means dependencies. + */ + private refuseCustomerAuthoredToggle(name: string, flow: FlowParsed, enabled: boolean): void { + if (describeFlowContender(flow).source === 'package') return; + throw Object.assign( + new Error( + `Flow '${name}' cannot be ${enabled ? 'enabled' : 'disabled'} through this switch: the switch turns ` + + `packaged flows on and off, and '${name}' was authored in this deployment, not shipped by a ` + + `package. It records an installation's choice about a packaged flow in the activation ledger ` + + `(sys_metadata_activation, ADR-0126 §7.2), which holds no customer-authored flow. This flow's ` + + `switch is its own definition's status: publish it through its update door, ` + + `PUT /automation/${name}, which takes the complete definition, with status 'obsolete' to ` + + `switch it off or 'active' to arm it. Nothing was changed.`, + ), + { code: 'RESOURCE_CONFLICT', status: 409 }, + ); + } + + /** + * [ADR-0126 §7.2] Flip a PACKAGED flow's activation — THE sanctioned + * off-switch for packaged flows. A flow no package ships is refused + * ({@link refuseCustomerAuthoredToggle}); its switch is its `status`. * * ## What changed, and why the durable write is inside this method * @@ -5159,11 +5220,13 @@ export class AutomationEngine implements IAutomationService { * every other service method; the wire is the untrusted surface, and the * wire goes through the gate. * - * @throws when the flow is unknown, when §7.3's subflow guard refuses in - * either direction, when the durable suspended-run store cannot be - * listed while the disable guard judges a switched-off caller, or when - * the durable write fails — a reported flip that did not persist is the - * failure mode this whole leg exists to remove. + * @throws when the flow is unknown, when no package ships it + * (`RESOURCE_CONFLICT` / 409, before anything is written or changed), + * when §7.3's subflow guard refuses in either direction, when the + * durable suspended-run store cannot be listed while the disable guard + * judges a switched-off caller, or when the durable write fails — a + * reported flip that did not persist is the failure mode this whole leg + * exists to remove. */ async toggleFlow(name: string, enabled: boolean): Promise { const flow = this.flows.get(name); @@ -5171,6 +5234,14 @@ export class AutomationEngine implements IAutomationService { throw new Error(`Flow '${name}' not found`); } + // [#20726, ADR-0126 §7.2] FIRST, ahead of both §7.3 guards: a flow no + // package ships is not this door's to switch in either direction, so + // neither guard's question arises, the disable guard's run-store read + // is never awaited for it, and the refusal lands before the ledger + // write and before any in-process change — with a ledger attached or + // in the degraded mode without one. Nothing half-flips. + this.refuseCustomerAuthoredToggle(name, flow, enabled); + // [ADR-0126 §7.3] The subflow guard runs in BOTH directions, because // a subflow pair breaks from either end. Disabling a child breaks the // callers that still reach it — armed, or holding a parked run; diff --git a/packages/services/service-automation/src/flow-activation-ledger.test.ts b/packages/services/service-automation/src/flow-activation-ledger.test.ts index c50b9b16f02..31e6caff878 100644 --- a/packages/services/service-automation/src/flow-activation-ledger.test.ts +++ b/packages/services/service-automation/src/flow-activation-ledger.test.ts @@ -829,20 +829,30 @@ describe('ADR-0126 §7.3 (enable direction) — re-enabling a packaged caller is await expect(engine.toggleFlow('head', true)).resolves.toBeUndefined(); }); - it('a NON-packaged caller is not guarded — a tenant\'s own flow is theirs to arm', async () => { - const { engine } = runnableEngine(); + it('a NON-packaged caller is not guarded — a tenant\'s own flow is theirs to arm, through its status', async () => { + const { engine, triggers } = runnableEngine(); engine.registerFlow('shared_step', packagedFlow('shared_step')); await engine.toggleFlow('shared_step', false); - engine.registerFlow('my_own_process', { ...caller('my_own_process', ['shared_step']), _packageId: undefined }); - await engine.toggleFlow('my_own_process', false); - - await expect(engine.toggleFlow('my_own_process', true)).resolves.toBeUndefined(); + const own = (status: string) => ({ ...caller('my_own_process', ['shared_step']), _packageId: undefined, status }); + engine.registerFlow('my_own_process', own('obsolete')); + + // [#20726] This switch is not a customer flow's: it is refused for its + // provenance, before §7.3 is asked — so no subflow is named. + const thrown = await engine.toggleFlow('my_own_process', true).then(() => undefined, (e: unknown) => e); + expect(thrown).toMatchObject({ code: 'RESOURCE_CONFLICT', status: 409 }); + expect((thrown as Error).message).not.toContain('shared_step'); + + // Its own switch arms it, onto the switched-off packaged subflow: + // §7.3 guards packaged callers only. + engine.registerFlow('my_own_process', own('active')); + expect(triggers.record_change.isBound('my_own_process')).toBe(true); }); it('a NON-packaged subflow does not guard a packaged caller', async () => { const { engine } = runnableEngine(); - engine.registerFlow('my_step', { ...packagedFlow('my_step'), _packageId: undefined }); - await engine.toggleFlow('my_step', false); + // [#20726] Switched off through its own switch, its status — the + // toggle door refuses a flow no package ships. + engine.registerFlow('my_step', { ...packagedFlow('my_step'), _packageId: undefined, status: 'obsolete' }); engine.registerFlow('vendor_process', caller('vendor_process', ['my_step'])); await engine.toggleFlow('vendor_process', false); diff --git a/packages/services/service-automation/src/flow-label-on-result.test.ts b/packages/services/service-automation/src/flow-label-on-result.test.ts index 04a0b46e615..fd4df09fdeb 100644 --- a/packages/services/service-automation/src/flow-label-on-result.test.ts +++ b/packages/services/service-automation/src/flow-label-on-result.test.ts @@ -282,7 +282,8 @@ describe('AutomationResult.flowLabel — the authored flow label rides the resul describe('absent — a refusal carrying `code`, or no registered flow', () => { it('never-dispatched refusals: FLOW_DISABLED and FLOW_NO_START_NODE', async () => { - engine.registerFlow('approve_orders', chain('approve_orders', PARENT_LABEL, [{ id: 'w', type: 'work' }]) as never); + // [#20726] Disabled through the toggle, which switches packaged flows only. + engine.registerFlow('approve_orders', { ...chain('approve_orders', PARENT_LABEL, [{ id: 'w', type: 'work' }]), _packageId: 'crm' } as never); await engine.toggleFlow('approve_orders', false); const disabled = await engine.execute('approve_orders'); expect(disabled.code).toBe('FLOW_DISABLED'); diff --git a/packages/services/service-automation/src/flow-terminal-messages.test.ts b/packages/services/service-automation/src/flow-terminal-messages.test.ts index c6e480dd57d..f59b1797db1 100644 --- a/packages/services/service-automation/src/flow-terminal-messages.test.ts +++ b/packages/services/service-automation/src/flow-terminal-messages.test.ts @@ -210,7 +210,8 @@ describe('#9414 — the boundary: which exits must NOT carry a message', () => { it('a NEVER-DISPATCHED exit carries neither — a disabled flow has no terminal state', async () => { const { engine } = engineWith('pass'); - engine.registerFlow('notify_owner', messageFlow('notify_owner') as never); + // [#20726] Disabled through the toggle, which switches packaged flows only. + engine.registerFlow('notify_owner', { ...messageFlow('notify_owner'), _packageId: 'crm' } as never); engine.toggleFlow('notify_owner', false); const result = await engine.execute('notify_owner'); diff --git a/packages/services/service-automation/src/node-type-vocabulary-seal-warning.test.ts b/packages/services/service-automation/src/node-type-vocabulary-seal-warning.test.ts index 67c84c65a5a..2c98462c68e 100644 --- a/packages/services/service-automation/src/node-type-vocabulary-seal-warning.test.ts +++ b/packages/services/service-automation/src/node-type-vocabulary-seal-warning.test.ts @@ -144,7 +144,8 @@ describe('#4792 — a never-sealed node-type vocabulary announces itself at the it('does not fire for an unknown or disabled flow name — the trigger is a real run', async () => { const lines: string[] = []; const engine = new AutomationEngine(loggerCapturing(lines)); - engine.registerFlow('off', trivialFlow('off')); + // [#20726] Disabled through the toggle, which switches packaged flows only. + engine.registerFlow('off', { ...trivialFlow('off'), _packageId: 'crm' }); await engine.toggleFlow('off', false); expect((await engine.execute('never_registered')).success).toBe(false); From c11a4f1a6ea24e11b28c1c3d36fb7d0bc39583b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:01:39 +0000 Subject: [PATCH 03/12] test(service-automation): pin the refusal for a customer flow a ledger 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 Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H --- .../src/toggle-door-packaged-only.test.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/services/service-automation/src/toggle-door-packaged-only.test.ts b/packages/services/service-automation/src/toggle-door-packaged-only.test.ts index 4bbf02c1f1d..c1a98c5bcff 100644 --- a/packages/services/service-automation/src/toggle-door-packaged-only.test.ts +++ b/packages/services/service-automation/src/toggle-door-packaged-only.test.ts @@ -18,7 +18,9 @@ // ledger rows, the trigger binding and the `/_status` row — never off the // guard's body: // 1. a customer-authored flow toggled through the door gets the named -// refusal, and the ledger and the flow are unchanged; +// refusal, and the ledger and the flow are unchanged — including one a +// ledger row already holds off, whose refusal names the step that +// completes for it; // 2. a packaged flow still toggles (the control); // 3. a customer flow published with `status: 'obsolete'` is not armed — the // switch the refusal names works. @@ -161,6 +163,38 @@ describe('[#20726] pin 1 — the toggle door refuses a customer-authored flow, a const warned = logger.warn.mock.calls.map((c: unknown[]) => String(c[0])); expect(warned.some((m: string) => m.includes('IN PROCESS ONLY'))).toBe(false); }); + + // A customer flow can ALREADY be held off by a ledger row under its name: + // the door used to accept a flow whose package id was non-empty (the + // sentinel and app-bound shapes above) and wrote one, and a customer + // overlay can shadow a packaged flow the ledger switched off. A status + // does not clear such a row, so naming only the status would prescribe a + // step that completes nothing. The refusal names the step that does. + it('a customer flow a ledger row already holds off: still refused, the row untouched, and the step it names completes', async () => { + const { engine, store, records } = engineWithLedger(); + await store.setActive({ name: 'customer_flow', packageId: 'sys_metadata', active: false }); + engine.registerFlow('customer_flow', flowBody('customer_flow', { _packageId: 'sys_metadata', status: 'active' })); + await engine.hydrateFlowActivations(); + const before = stateOf(engine, 'customer_flow'); + expect(before).toMatchObject({ enabled: false, bound: false }); + + const refusal = await refusalOf(engine.toggleFlow('customer_flow', true)); + + expect(refusal.code).toBe('RESOURCE_CONFLICT'); + expect(refusal.status).toBe(409); + expect(refusal.message).toContain('PUT /automation/customer_flow'); + expect(refusal.message).toContain('POST /automation/customer_flow/clone'); + expect(await store.list()).toEqual([{ name: 'customer_flow', packageId: 'sys_metadata', active: false }]); + expect(stateOf(engine, 'customer_flow')).toEqual(before); + + // Its status does not arm it — which is why the refusal cannot stop there… + engine.registerFlow('customer_flow', flowBody('customer_flow', { _packageId: 'sys_metadata', status: 'active' })); + expect(records.isBound('customer_flow')).toBe(false); + // …and the named step completes: the clone door registers the whole + // definition under a new name, with no package envelope, and it is armed. + engine.registerFlow('customer_flow_copy', flowBody('customer_flow_copy', { status: 'draft' })); + expect(records.isBound('customer_flow_copy')).toBe(true); + }); }); describe('[#20726] pin 2 — a packaged flow still toggles (the control)', () => { From 94a4e3af64812db10f47455386f8deef08f57a48 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:04:14 +0000 Subject: [PATCH 04/12] fix(service-automation): the refusal names a step that completes for 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 Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H --- .../services/service-automation/src/engine.ts | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 1ce5575c85d..1aea3ac4b60 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -5176,18 +5176,38 @@ export class AutomationEngine implements IAutomationService { * minted. Not a 400: nothing about the request is malformed. Not a 403: * no caller could be authorized into it. Not `DELETE_RESTRICTED`: that * member means dependencies. + * + * ## A customer flow a ledger row ALREADY holds off + * + * The ledger is keyed by name, and a row can already stand under a + * customer flow's name: this door used to accept a customer flow whose + * package id was non-empty (the sentinel and app-bound shapes above) and + * wrote one, and a customer overlay can shadow a packaged flow the ledger + * switched off. A status does not clear such a row ({@link isFlowEnabled} + * composes the two, neither overrides the other), so for that flow the + * refusal must not stop at "publish it `active`" — a step that completes + * nothing. It names the one that does, and the one the `FLOW_DISABLED` + * refusal already names for a ledger-held flow: a clone under a new name + * (ADR-0126 §7.1), which no row holds. Still nothing is written: which + * rows this door should clear is not this refusal's to decide. */ private refuseCustomerAuthoredToggle(name: string, flow: FlowParsed, enabled: boolean): void { if (describeFlowContender(flow).source === 'package') return; + const updateDoor = `its update door, PUT /automation/${name}, which takes the complete definition`; + const ownSwitch = this.flowLedgerDisabled.has(name) + ? `This flow's own switch is its definition's status, published through ${updateDoor} — but it is ` + + `ALSO held off by an activation-ledger row recorded under the name '${name}' (for a packaged flow of ` + + `that name, or by this switch before it refused customer-authored flows), and no status clears that ` + + `row. To run this flow, clone it under a new name through POST /automation/${name}/clone, which ` + + `arms the copy, and remove this one.` + : `This flow's switch is its own definition's status: publish it through ${updateDoor}, with status ` + + `'obsolete' to switch it off or 'active' to arm it.`; throw Object.assign( new Error( `Flow '${name}' cannot be ${enabled ? 'enabled' : 'disabled'} through this switch: the switch turns ` + `packaged flows on and off, and '${name}' was authored in this deployment, not shipped by a ` + - `package. It records an installation's choice about a packaged flow in the activation ledger ` + - `(sys_metadata_activation, ADR-0126 §7.2), which holds no customer-authored flow. This flow's ` + - `switch is its own definition's status: publish it through its update door, ` + - `PUT /automation/${name}, which takes the complete definition, with status 'obsolete' to ` + - `switch it off or 'active' to arm it. Nothing was changed.`, + `package. The switch records an installation's choice about a packaged flow in the activation ` + + `ledger (sys_metadata_activation, ADR-0126 §7.2). ${ownSwitch} Nothing was changed.`, ), { code: 'RESOURCE_CONFLICT', status: 409 }, ); From f68a67ea986565794ffb8412f4339dccd75ebd12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:05:40 +0000 Subject: [PATCH 05/12] docs(spec,client,runtime): the toggle door switches packaged flows; a 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 Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H --- packages/client/src/index.ts | 14 +++++++++++--- packages/runtime/src/domains/automation.ts | 12 ++++++++++-- packages/spec/src/api/automation-api.zod.ts | 10 +++++++++- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 19305adddbf..cabb6e00308 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -5483,9 +5483,6 @@ export class ObjectStackClient { return this.unwrapResponse(res); }, - /** - * Enable or disable a flow - */ /* [#3563 PR-5] The three descriptor/status routes that had no SDK * expression — they back the Studio designer's pickers and badges. */ @@ -5525,6 +5522,17 @@ export class ObjectStackClient { return this.unwrapResponse(res); }, + /** + * Enable or disable a PACKAGED flow — one a code package ships. + * + * [#20726, ADR-0126 §7.2] `POST /automation/:name/toggle` records the + * installation's choice in the packaged-metadata activation ledger, so + * it switches packaged flows only. A flow authored in the deployment is + * refused with 409 `RESOURCE_CONFLICT` and nothing changes. Its switch + * is its own `status`: send its complete definition through + * `automation.update(name, definition)` (`PUT /automation/:name`) with + * `status: 'obsolete'` to disarm it, or `status: 'active'` to arm it. + */ toggle: async (name: string, enabled: boolean): Promise<{ name: string; enabled: boolean }> => { const route = this.getRoute('automation'); const res = await this.fetch(`${this.baseUrl}${route}/${name}/toggle`, { diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 86c5c6f0b7f..c42cdf156ea 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -412,7 +412,10 @@ function isFlowEnablementWrite(parts: string[], method: string): boolean { * `POST /` → registerFlow (create) * `PUT /:name` → registerFlow (update) * `DELETE /:name` → unregisterFlow (deregister) - * `POST /:name/toggle` → toggleFlow (enablement — commit 266436a7f, see below) + * `POST /:name/toggle` → toggleFlow (enablement — commit 266436a7f, see below; + * PACKAGED flows only, #20726: a customer + * flow's switch is its `status`, via + * `PUT /:name`) * * ## [commit 266436a7f] Why `toggle` joins them — ruled, not inferred * @@ -1904,7 +1907,12 @@ export async function classifyResumeResult( * ran and failed → 400 `FLOW_FAILED`; #9378 + #9415; * a run that PAUSED → 200 with `runId` / `screen`, * on whichever attempt it paused — #9510) - * POST /:name/toggle → toggleFlow (unknown name → 404, #7535) + * POST /:name/toggle → toggleFlow (unknown name → 404, #7535). Switches + * PACKAGED flows only — it writes the ADR-0126 §7.2 + * activation ledger. A flow no package ships → 409 + * `RESOURCE_CONFLICT` naming that flow's own switch, + * its `status` ('obsolete' / 'active') published + * through `PUT /:name`; nothing changes (#20726) * ⚑ authoring write — `manage_metadata` (commit 266436a7f): * enablement is environment-wide, so an * unentitled toggle reached every organization diff --git a/packages/spec/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts index 720ad6ffa5d..62a1b6b4218 100644 --- a/packages/spec/src/api/automation-api.zod.ts +++ b/packages/spec/src/api/automation-api.zod.ts @@ -25,6 +25,14 @@ import { ExecutionLogSchema, ExecutionStatus, FlowRunSummarySchema } from '../au * and `client.automation.list` are retired (ADR-0087 semantic entry * `automation-flow-list-route-retired`). * + * The toggle door switches PACKAGED flows only: a flow a code package ships. + * It records the installation's choice in the packaged-metadata activation + * ledger (ADR-0126 §7.2). A flow authored in the deployment is not switched + * there. Its switch is its own `status`: `'obsolete'` disarms it and + * `'active'` arms it, published with the complete definition through + * `PUT /api/v1/automation/:name`. The toggle door refuses such a flow with + * 409 `RESOURCE_CONFLICT`, names that switch, and changes nothing. + * * @example Endpoints * ``` * GET /api/v1/automation/:name — Get flow @@ -32,7 +40,7 @@ import { ExecutionLogSchema, ExecutionStatus, FlowRunSummarySchema } from '../au * PUT /api/v1/automation/:name — Update flow * DELETE /api/v1/automation/:name — Delete flow * POST /api/v1/automation/:name/trigger — Trigger flow execution - * POST /api/v1/automation/:name/toggle — Enable/disable flow + * POST /api/v1/automation/:name/toggle — Enable/disable a packaged flow * GET /api/v1/automation/:name/runs — List execution runs * GET /api/v1/automation/:name/runs/:runId — Get single execution run * ``` From ea633b383c273d7424b7de1ce509280ee1c09594 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:15:06 +0000 Subject: [PATCH 06/12] docs(references): regenerate the Automation API page from its docblock 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 Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H --- content/docs/references/api/automation-api.mdx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 486d8d43851..201706f90e5 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -25,6 +25,14 @@ the former `GET /api/v1/automation` list route, its request/response schemas and `client.automation.list` are retired (ADR-0087 semantic entry `automation-flow-list-route-retired`). +The toggle door switches PACKAGED flows only: a flow a code package ships. +It records the installation's choice in the packaged-metadata activation +ledger (ADR-0126 §7.2). A flow authored in the deployment is not switched +there. Its switch is its own `status`: `'obsolete'` disarms it and +`'active'` arms it, published with the complete definition through +`PUT /api/v1/automation/:name`. The toggle door refuses such a flow with +409 `RESOURCE_CONFLICT`, names that switch, and changes nothing. + **Endpoints** ``` GET /api/v1/automation/:name — Get flow @@ -32,7 +40,7 @@ POST /api/v1/automation — Create flow PUT /api/v1/automation/:name — Update flow DELETE /api/v1/automation/:name — Delete flow POST /api/v1/automation/:name/trigger — Trigger flow execution -POST /api/v1/automation/:name/toggle — Enable/disable flow +POST /api/v1/automation/:name/toggle — Enable/disable a packaged flow GET /api/v1/automation/:name/runs — List execution runs GET /api/v1/automation/:name/runs/:runId — Get single execution run ``` From ae47cf59bb23dba6bff402caf6d2c734ee830d41 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:18:56 +0000 Subject: [PATCH 07/12] chore(changeset): release notes for the packaged-only toggle door; the 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 Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H --- .changeset/20726-toggle-door-docs-client.md | 7 ++++++ .changeset/20726-toggle-door-docs-spec.md | 7 ++++++ .changeset/20726-toggle-door-packaged-only.md | 23 +++++++++++++++++++ .../services/service-automation/README.md | 12 ++++++++-- 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 .changeset/20726-toggle-door-docs-client.md create mode 100644 .changeset/20726-toggle-door-docs-spec.md create mode 100644 .changeset/20726-toggle-door-packaged-only.md diff --git a/.changeset/20726-toggle-door-docs-client.md b/.changeset/20726-toggle-door-docs-client.md new file mode 100644 index 00000000000..da949d8df0e --- /dev/null +++ b/.changeset/20726-toggle-door-docs-client.md @@ -0,0 +1,7 @@ +--- +'@objectstack/client': patch +--- + +docs(client): `automation.toggle` says it switches packaged flows only, and names a customer flow's switch (#20726) + +`client.automation.toggle` had no docblock of its own: its one line had drifted above an unrelated member. It now says that it switches packaged flows only, and that a flow authored in the deployment is refused with 409 `RESOURCE_CONFLICT`. Such a flow's switch is its `status`, sent with the complete definition through `automation.update`. This is prose only: the method's signature and behaviour are unchanged. diff --git a/.changeset/20726-toggle-door-docs-spec.md b/.changeset/20726-toggle-door-docs-spec.md new file mode 100644 index 00000000000..ac89fd6b21b --- /dev/null +++ b/.changeset/20726-toggle-door-docs-spec.md @@ -0,0 +1,7 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): the Automation API docblock says the toggle door switches packaged flows only, and names a customer flow's switch (#20726) + +The module docblock of `api/automation-api.zod.ts` listed `POST /api/v1/automation/:name/toggle` as "Enable/disable flow". That file ships as source, and its docblock is also the source of the Automation API reference page. The line now reads "Enable/disable a packaged flow". A new paragraph says what a flow authored in the deployment uses instead: its `status`, published with the complete definition through `PUT /api/v1/automation/:name`. The toggle door refuses such a flow with 409 `RESOURCE_CONFLICT`. This is prose only: no schema, type or export changes. diff --git a/.changeset/20726-toggle-door-packaged-only.md b/.changeset/20726-toggle-door-packaged-only.md new file mode 100644 index 00000000000..b62bdc63f06 --- /dev/null +++ b/.changeset/20726-toggle-door-packaged-only.md @@ -0,0 +1,23 @@ +--- +'@objectstack/service-automation': minor +--- + +fix(service-automation)!: the toggle door refuses a flow no package ships, naming its status switch (#20726) + +Clause-②: no (narrowing) + + + +**BREAKING**: shipped as `minor` under the launch-window convention. `toggleFlow(name, enabled)` on the automation service, and so `POST /api/v1/automation/:name/toggle` and `client.automation.toggle`, now switches packaged flows only: a flow a code package ships. + +**What was wrong.** The switch records an installation's choice in the packaged-metadata activation ledger (`sys_metadata_activation`, ADR-0126 §7.2), whose rows name the package that ships the flow. For a flow authored in the deployment it wrote a row anyway: + +- A flow with no package id, such as one created through `POST /api/v1/automation` or the clone door, was refused with 400 `VALIDATION_FAILED` "Package is required", naming a field the caller never sent. +- A flow carrying the runtime-row package sentinel or an app package id was accepted, and a ledger row was written for it. That gave it a second off-switch beside its own `status`. +- With no ledger attached, the flip was accepted in process only. + +**What changed.** A flow without package provenance is now refused with `RESOURCE_CONFLICT` / `409`, in both directions and with or without a ledger. The refusal comes before anything is written or changed. The message says the switch turns packaged flows on and off. It names the flow's own switch: its definition's `status`, published with the complete definition through `PUT /api/v1/automation/:name`. `obsolete` switches it off and `active` arms it. The switch never rewrites a definition itself. Packaged flows toggle exactly as before. + +**Migration.** To switch a customer-authored flow off, stop sending `POST /api/v1/automation/NAME/toggle` with `{"enabled": false}`. Instead, send `PUT /api/v1/automation/NAME` with the flow's complete definition and `status: 'obsolete'`, and `status: 'active'` to arm it again. In the SDK, `client.automation.toggle(name, false)` becomes `client.automation.update(name, { ...definition, status: 'obsolete' })`. + +**A customer flow that a ledger row already holds off.** If this switch turned a customer flow off before this release, its ledger row still holds the flow off after the upgrade, and no `status` clears that row. The refusal says so and names the step that completes: clone the flow under a new name through `POST /api/v1/automation/NAME/clone`, which arms the copy, then remove the old one. diff --git a/packages/services/service-automation/README.md b/packages/services/service-automation/README.md index f025c733844..720c02a949c 100644 --- a/packages/services/service-automation/README.md +++ b/packages/services/service-automation/README.md @@ -268,7 +268,15 @@ automation.registerFlow?.('escalate_high_priority_case', escalateCase); const names = await automation.listFlows(); // string[] of machine names const parsed = await automation.getFlow?.('escalate_high_priority_case'); -await automation.toggleFlow?.('escalate_high_priority_case', false); + +// A flow registered here is authored in the deployment, so its switch is its +// own `status`: re-register it with 'obsolete' to switch it off ('active' arms it). +automation.registerFlow?.('escalate_high_priority_case', { ...escalateCase, status: 'obsolete' }); + +// `toggleFlow` switches PACKAGED flows only — one a code package ships — by +// writing the ADR-0126 activation ledger. It refuses a flow no package ships +// with RESOURCE_CONFLICT / 409 and changes nothing. +await automation.toggleFlow?.('a_packaged_flow', false); ``` `registerFlow` validates against the live action registry and rejects unknown `config` @@ -292,7 +300,7 @@ GET /api/v1/automation/:name # get one flow PUT /api/v1/automation/:name # update a flow DELETE /api/v1/automation/:name # delete a flow POST /api/v1/automation/:name/trigger # execute a flow -POST /api/v1/automation/:name/toggle # enable / disable +POST /api/v1/automation/:name/toggle # enable / disable a packaged flow (a customer flow: its status, via PUT) GET /api/v1/automation/:name/runs # list runs GET /api/v1/automation/:name/runs/:runId # run detail GET /api/v1/automation/:name/runs/:runId/screen # screen spec of a parked run From 290e9ad6fcc513c560a20a94cf1bf32dc59b31aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:18:56 +0000 Subject: [PATCH 08/12] docs(changeset): correct a pending release note this change makes false 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 Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H --- .changeset/20678-subflow-disable-sequence.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/20678-subflow-disable-sequence.md b/.changeset/20678-subflow-disable-sequence.md index 6f5e42946f0..10e8bd53c9d 100644 --- a/.changeset/20678-subflow-disable-sequence.md +++ b/.changeset/20678-subflow-disable-sequence.md @@ -18,7 +18,7 @@ Clause-②: no (narrowing) **Not refused:** - Enabling a flow that is already enabled. Nothing is re-armed. -- A flow the customer authored, or a subflow the customer authored. +- A subflow the customer authored. A flow the customer authored is not this switch's to enable at all: the activation switch switches packaged flows only, and it refuses a customer-authored flow for that reason before this guard is asked (see the entry "the toggle door refuses a flow no package ships, naming its status switch"). - A subflow in a cycle of switched-off flows with the flow being enabled, including a flow that calls itself. Each flow in such a cycle would refuse the others, so no order could complete. A subflow in such a cycle whose definition's `status` also disables it is still named, with its publish remedy: no enable order changes a status. The disable direction of the same guard is described in its own entry, "disabling a packaged subflow completes once its packaged callers are switched off and hold no parked run". From ebf387564ecfbc2a8e517c9ae8575a499b19e501 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:19:50 +0000 Subject: [PATCH 09/12] test(runtime): the clone notice must name the clone's own off-switch (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 Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H --- packages/runtime/src/domains/automation-flow-clone.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/domains/automation-flow-clone.test.ts b/packages/runtime/src/domains/automation-flow-clone.test.ts index 2ef9ba8ac08..204040ea62f 100644 --- a/packages/runtime/src/domains/automation-flow-clone.test.ts +++ b/packages/runtime/src/domains/automation-flow-clone.test.ts @@ -456,7 +456,11 @@ describe('#12156 — the source, the notice, and the gate', () => { // `obsolete`/`invalid`, so a cloned record-change flow runs beside the // one it was copied from. Saying so is cheaper than the surprise. expect(notice).toMatch(/off-switch/i); - expect(notice).toContain('toggle'); + // [#20726] And the off-switch it names is one the clone has: its own + // `status`, through its update door. A clone carries no package + // envelope, and the activation toggle switches packaged flows only. + expect(notice).toContain("status: 'obsolete'"); + expect(notice).toContain('PUT /api/v1/automation/'); }); it('is an authoring write — a caller without `manage_metadata` is refused 403, nothing registered', async () => { From 42d7a9a42f87cd36eb0d179a2f6f8802c3bd2db7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:20:25 +0000 Subject: [PATCH 10/12] fix(runtime): the clone door's notice names the clone's own off-switch, 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 Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H --- .changeset/20726-clone-notice-status-switch.md | 7 +++++++ packages/runtime/src/flow-clone.ts | 13 +++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 .changeset/20726-clone-notice-status-switch.md diff --git a/.changeset/20726-clone-notice-status-switch.md b/.changeset/20726-clone-notice-status-switch.md new file mode 100644 index 00000000000..902733fdc76 --- /dev/null +++ b/.changeset/20726-clone-notice-status-switch.md @@ -0,0 +1,7 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): the clone door's notice names the clone's own off-switch, its status (#20726) + +`POST /api/v1/automation/:name/clone` answers a `notice` saying the clone is armed. It told the admin to switch the clone off through `POST /api/v1/automation/NAME/toggle`. A clone carries no package envelope, so it is a flow authored in the deployment, and that switch refuses it: the switch turns packaged flows on and off only. The notice now names the clone's own switch: send its complete definition with `status: 'obsolete'` to `PUT /api/v1/automation/NAME`. It also says that the toggle switches packaged flows only, such as the flow the clone was copied from. The response shape is unchanged; only the notice text moves. diff --git a/packages/runtime/src/flow-clone.ts b/packages/runtime/src/flow-clone.ts index 5008277ee69..03b9a061806 100644 --- a/packages/runtime/src/flow-clone.ts +++ b/packages/runtime/src/flow-clone.ts @@ -147,14 +147,23 @@ export const FLOW_CLONE_DROPPED_KEYS: readonly string[] = Object.freeze([ * record-change flow and walks away has two flows running on one trigger, * and the only thing standing between them and that surprise is this * sentence. + * + * [#20726, ADR-0126 §7.2] The off-switch it names is the CLONE's own: its + * `status`, published through `PUT /:name`. A clone carries no package + * envelope (see {@link FLOW_CLONE_DROPPED_KEYS}), so it is a flow authored in + * this deployment, and the activation toggle — which switches packaged flows + * only — refuses it. The toggle is named for the flow it was copied from, + * which is the packaged one. */ export const FLOW_CLONE_NOTICE = 'References are not re-pointed: this clone calls exactly what the original called ' + '(subflows, actions and objects are unchanged). It is created with status `draft`, ' + 'which is a lifecycle label and NOT an off-switch — a cloned record-change or schedule ' + 'flow is bound to its trigger and will run alongside the flow it was copied from. ' - + 'Disable it (`POST /api/v1/automation//toggle` with `{"enabled": false}`) if that ' - + 'is not what you want.'; + + 'If that is not what you want, switch the clone off through its own status: send its ' + + 'complete definition with `status: \'obsolete\'` to `PUT /api/v1/automation/`. ' + + 'The activation toggle (`POST /api/v1/automation//toggle`) switches packaged flows ' + + 'only, such as the one it was copied from, and refuses the clone.'; /** ADR-0112 envelope for the same-name refusal: a status AND a code. */ export const FLOW_CLONE_NAME_TAKEN_STATUS = 409; From d7eb865aa51fa5f95dddb8f24a7f04705e144ef9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:20:53 +0000 Subject: [PATCH 11/12] docs(spec): the IAutomationService.toggleFlow contract says it switches 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 Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H --- .changeset/20726-toggle-door-docs-spec.md | 2 +- packages/spec/src/contracts/automation-service.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.changeset/20726-toggle-door-docs-spec.md b/.changeset/20726-toggle-door-docs-spec.md index ac89fd6b21b..bb9db6effc9 100644 --- a/.changeset/20726-toggle-door-docs-spec.md +++ b/.changeset/20726-toggle-door-docs-spec.md @@ -4,4 +4,4 @@ docs(spec): the Automation API docblock says the toggle door switches packaged flows only, and names a customer flow's switch (#20726) -The module docblock of `api/automation-api.zod.ts` listed `POST /api/v1/automation/:name/toggle` as "Enable/disable flow". That file ships as source, and its docblock is also the source of the Automation API reference page. The line now reads "Enable/disable a packaged flow". A new paragraph says what a flow authored in the deployment uses instead: its `status`, published with the complete definition through `PUT /api/v1/automation/:name`. The toggle door refuses such a flow with 409 `RESOURCE_CONFLICT`. This is prose only: no schema, type or export changes. +The module docblock of `api/automation-api.zod.ts` listed `POST /api/v1/automation/:name/toggle` as "Enable/disable flow". That file ships as source, and its docblock is also the source of the Automation API reference page. The line now reads "Enable/disable a packaged flow". A new paragraph says what a flow authored in the deployment uses instead: its `status`, published with the complete definition through `PUT /api/v1/automation/:name`. The toggle door refuses such a flow with 409 `RESOURCE_CONFLICT`. The `IAutomationService.toggleFlow` docblock, which read "Enable or disable a flow", says the same. This is prose only: no schema, type or export changes. diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 002654f2027..12d33b6bc0c 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -716,7 +716,12 @@ export interface IAutomationService { getFlow?(name: string): Promise; /** - * Enable or disable a flow + * Enable or disable a PACKAGED flow — one a code package ships — by + * recording the installation's choice in the activation ledger + * (ADR-0126 §7.2). A flow authored in the deployment is not this switch's: + * the automation service refuses it with `RESOURCE_CONFLICT` / 409 and + * changes nothing. That flow's switch is its own `status` (`'obsolete'` / + * `'active'`), published through {@link registerFlow}. * @param name - Flow name (snake_case) * @param enabled - Whether to enable (true) or disable (false) */ From 843341912c6e7744d0d0d1f71b81011fcbf64c3c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 05:39:28 +0000 Subject: [PATCH 12/12] fix(runtime): the clone notice makes no claim about the clone's source 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 Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H --- .changeset/20726-clone-notice-status-switch.md | 2 +- packages/runtime/src/flow-clone.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.changeset/20726-clone-notice-status-switch.md b/.changeset/20726-clone-notice-status-switch.md index 902733fdc76..90e1284cd02 100644 --- a/.changeset/20726-clone-notice-status-switch.md +++ b/.changeset/20726-clone-notice-status-switch.md @@ -4,4 +4,4 @@ fix(runtime): the clone door's notice names the clone's own off-switch, its status (#20726) -`POST /api/v1/automation/:name/clone` answers a `notice` saying the clone is armed. It told the admin to switch the clone off through `POST /api/v1/automation/NAME/toggle`. A clone carries no package envelope, so it is a flow authored in the deployment, and that switch refuses it: the switch turns packaged flows on and off only. The notice now names the clone's own switch: send its complete definition with `status: 'obsolete'` to `PUT /api/v1/automation/NAME`. It also says that the toggle switches packaged flows only, such as the flow the clone was copied from. The response shape is unchanged; only the notice text moves. +`POST /api/v1/automation/:name/clone` answers a `notice` saying the clone is armed. It told the admin to switch the clone off through `POST /api/v1/automation/NAME/toggle`. A clone carries no package envelope, so it is a flow authored in the deployment, and that switch refuses it: the switch turns packaged flows on and off only. The notice now names the clone's own switch: send its complete definition with `status: 'obsolete'` to `PUT /api/v1/automation/NAME`. It also says that the toggle switches packaged flows only and refuses the clone, whatever flow the clone was copied from. The response shape is unchanged; only the notice text moves. diff --git a/packages/runtime/src/flow-clone.ts b/packages/runtime/src/flow-clone.ts index 03b9a061806..02479d7b5b1 100644 --- a/packages/runtime/src/flow-clone.ts +++ b/packages/runtime/src/flow-clone.ts @@ -152,8 +152,9 @@ export const FLOW_CLONE_DROPPED_KEYS: readonly string[] = Object.freeze([ * `status`, published through `PUT /:name`. A clone carries no package * envelope (see {@link FLOW_CLONE_DROPPED_KEYS}), so it is a flow authored in * this deployment, and the activation toggle — which switches packaged flows - * only — refuses it. The toggle is named for the flow it was copied from, - * which is the packaged one. + * only — refuses it. That holds whatever the clone was copied from: the clone + * door takes any registered flow as its source, packaged or not, so the + * notice makes no claim about the source's provenance. */ export const FLOW_CLONE_NOTICE = 'References are not re-pointed: this clone calls exactly what the original called ' @@ -163,7 +164,7 @@ export const FLOW_CLONE_NOTICE = + 'If that is not what you want, switch the clone off through its own status: send its ' + 'complete definition with `status: \'obsolete\'` to `PUT /api/v1/automation/`. ' + 'The activation toggle (`POST /api/v1/automation//toggle`) switches packaged flows ' - + 'only, such as the one it was copied from, and refuses the clone.'; + + 'only and refuses the clone.'; /** ADR-0112 envelope for the same-name refusal: a status AND a code. */ export const FLOW_CLONE_NAME_TAKEN_STATUS = 409;