diff --git a/.changeset/olive-crabs-clone.md b/.changeset/olive-crabs-clone.md new file mode 100644 index 0000000000..c48fd29259 --- /dev/null +++ b/.changeset/olive-crabs-clone.md @@ -0,0 +1,43 @@ +--- +"@objectstack/runtime": minor +--- + +Add `POST /api/v1/automation/:name/clone` — whole-definition flow clone (ADR-0126 §7.1) + +An admin who cannot edit a packaged flow in place can now copy it to an ordinary +org-authored sibling and edit that instead. `POST /automation/:name/clone` takes +`{ name, label }` — both mandatory — and registers a copy of the source flow's +parsed definition under the new machine name. + +**The copy is whole-definition, never an enumerated facet list.** Every key the +source definition carries comes across; exactly `name`, `label` and `status` are +mutated. This is the shape ADR-0126 §7.1 rules for, and the reason is measured: +a clone assembled from an enumerated facet list silently dropped three of six +facets (#11703) — the record was created, the success toast fired, and the +difference was discoverable only by diffing the two rows. A flow has far more +facets than a permission set, so the acceptance test asserts deep equality of +the cloned definition against its source minus those three fields; a dropped +facet fails the test rather than shipping. + +**The new machine name is mandatory and a same-name clone is refused** with a +409 `RESOURCE_CONFLICT` naming both the reason and the remedy. Not because +storage rejects it — storage legitimately holds both rows — but because the +automation engine keys flows by bare name, so a second definition under one name +silently shadows the other and which of the two dispatches depends on +registration order. + +**No ancestry is recorded.** Nothing tracks what a clone was copied from — no +provenance field on the definition, none on the response (ADR-0126 amendment +ruling 2, §9). The source's own ADR-0010 protection envelope (`_packageId`, +`_provenance`, `_lock`, …) is dropped rather than carried across, so the clone is +an org-owned flow the admin can actually edit rather than a second copy of the +package's locked artifact. + +**References are not re-pointed** — no reference index exists, so the clone calls +exactly what the original called. The response says so, along with the fact that +`status: 'draft'` is a lifecycle label and not an off-switch: the engine only +disables on `obsolete`/`invalid`, so a cloned record-change or schedule flow is +bound to its trigger and runs alongside the flow it was copied from. + +The route joins the `manage_metadata` authoring-write set (#10145/#10243) — it +registers flow metadata at environment scope, exactly as `POST /automation` does. diff --git a/packages/runtime/src/domains/automation-flow-clone.test.ts b/packages/runtime/src/domains/automation-flow-clone.test.ts new file mode 100644 index 0000000000..8db5f6aaea --- /dev/null +++ b/packages/runtime/src/domains/automation-flow-clone.test.ts @@ -0,0 +1,502 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12156] `POST /automation/:name/clone` — ADR-0126 §7.1. + * + * The clone door an admin reaches for when a packaged flow cannot be edited in + * place. Three of the ADR's rules are the reason this file exists, and each has + * a measurement behind it rather than a preference: + * + * 1. **Whole-definition copy.** The centrepiece here is the #11703 + * counter-example expressed as a test: the cloned definition must deep-equal + * its source apart from the three fields a clone mutates. #11703 measured a + * clone assembled from an ENUMERATED facet list dropping three of six facets + * in silence — the record was created, the success toast fired, and the + * difference was discoverable only by diffing the two rows. A flow has far + * more facets than a permission set, so that failure mode is not merely + * available here, it is likely. Deep equality is the only assertion that + * fails when a facet goes missing; an assertion that spot-checks `nodes` and + * `edges` is the enumerated list again, wearing a test's clothes. + * + * 2. **No ancestry** (amendment ruling 2 / §9). Nothing records what the clone + * was copied from — not in the definition, not on the response. The + * converse is asserted too: the SOURCE's package provenance must not ride + * across, or the clone would carry `_packageId` naming the base's package, + * which is ancestry by another name (and would leave the clone locked and + * owned by the package the admin was trying to get out from under). + * + * 3. **Same-name is refused, loudly, with the sanctioned path.** Not because + * storage rejects it — storage legitimately holds both rows (ADR-0005 + * amendment, #6825) — but because the engine's flow map is keyed by BARE + * name, so the second definition silently shadows the first and the survivor + * is decided by registration order (#11665 §2.2, #11997). + * + * The harness is `automation-toggle-unknown-flow.test.ts`'s: a fake automation + * service holding exactly the flows it is given, driven through the real + * `HttpDispatcher` so the route, its gate and the envelope are all under test + * rather than the helper alone. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import { validationFailureDetails } from '../validation-failure.js'; +// Read from the implementation rather than restated: a second spelling of a +// contract value is a second contract, and it agrees only until one of them +// moves. (The wire status is asserted as the literal 409 beside it — that one +// IS the contract, so it is pinned independently of what the module calls it.) +import { + FLOW_CLONE_DROPPED_KEYS, + FLOW_CLONE_MUTATED_FIELDS, + FLOW_CLONE_NAME_TAKEN_STATUS, + FLOW_CLONE_STATUS, +} from '../flow-clone.js'; + +/** + * The caller holds `manage_metadata`. A clone REGISTERS flow metadata, so it + * sits inside the #10145 authoring-write set — without this every case below + * would stop at a 403 in front of the behaviour it is named after. The gate + * itself is asserted at the bottom of this file. + */ +const CTX = { request: {}, executionContext: { userId: 'user_1', systemPermissions: ['manage_metadata'] } } as any; + +/** + * A packaged exemplar carrying a facet of every kind a flow has: scalars, an + * enum, a number, nested arrays of objects, a nested config object, a nested + * `errorHandling` block — and the ADR-0010 protection envelope a packaged flow + * really does carry once the loader has registered it (`FlowSchema` spreads + * `MetadataProtectionFields`, so these are part of the parsed definition, not + * decoration around it). + * + * Deliberately fat. The #11703 lesson is that a clone test passes trivially + * when the fixture has nothing to lose, so {@link EXEMPLAR_FACET_FLOOR} below + * pins that this exemplar keeps exercising the assertion. + */ +function packagedExemplar(): Record { + return { + // ── the three fields a clone mutates ────────────────────────── + name: 'crm_opportunity_escalation', + label: 'Opportunity Escalation', + status: 'active', + // ── everything else must survive verbatim ───────────────────── + description: 'Escalates a stalled opportunity to the regional manager.', + successMessage: 'Escalation sent', + errorMessage: 'Escalation could not be sent', + version: 4, + type: 'record_change', + runAs: 'system', + variables: [ + { name: 'stale_days', type: 'number', defaultValue: 30 }, + { name: 'region', type: 'text' }, + ], + nodes: [ + { + id: 'start', + type: 'start', + label: 'Start', + config: { + objectName: 'opportunity', + triggerType: 'after_update', + condition: 'record.stage == "negotiation"', + }, + }, + { id: 'notify', type: 'notify', label: 'Notify manager', config: { channel: 'email', to: '{record.manager_email}' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'notify' }, + { id: 'e2', source: 'notify', target: 'end' }, + ], + errorHandling: { strategy: 'retry', maxRetries: 3, initialDelayMs: 1000 }, + // ── ADR-0010 protection envelope (loader-set, packaged flow) ── + _packageId: 'crm', + _packageVersion: '3.2.0', + _provenance: 'package', + _lock: 'no-overlay', + _lockReason: 'Shipped by the CRM package.', + _lockSource: 'package', + }; +} + +/** + * How many facet keys the exemplar must carry beyond the three a clone mutates + * and the envelope it drops. A future edit that thins the fixture out fails + * here rather than quietly turning the deep-equality assertion into a + * comparison of two nearly-empty objects. + */ +const EXEMPLAR_FACET_FLOOR = 10; + +/** + * The fake engine: `getFlow` resolves `null` for an unknown name (the real + * `engine.ts` returns `this.flows.get(name) ?? null`), `registerFlow` is + * synchronous and stores what it is given. + * + * It does NOT validate, on purpose — the real `registerFlow` canonicalizes and + * validates, and re-implementing that here would test this file's idea of the + * engine rather than the route. What is under test is which definition the + * route hands over. + */ +function makeDispatcher(seed: Record[] = [packagedExemplar()]) { + const flows = new Map>( + seed.map((f) => [f.name as string, f]), + ); + const spies = { + getFlow: vi.fn(async (name: string) => flows.get(name) ?? null), + registerFlow: vi.fn((name: string, definition: unknown) => { + flows.set(name, definition as Record); + }), + }; + const services: Record = { automation: spies }; + const resolve = (name: string) => services[name]; + const kernel: any = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { getService: resolve }, + }; + return { dispatcher: new HttpDispatcher(kernel), spies, flows }; +} + +/** Drop `keys` from a shallow copy — used to state "everything except …". */ +function omit(source: Record, keys: readonly string[]): Record { + const copy = { ...source }; + for (const key of keys) delete copy[key]; + return copy; +} + +const clonePath = (source: string) => `/${source}/clone`; +const NEW = { name: 'acme_opportunity_escalation', label: 'Opportunity Escalation (ACME)' }; + +describe('#12156 — whole-definition copy (the #11703 counter-example as a test)', () => { + it('carries EVERY facet: the clone deep-equals its source apart from name/label/status', async () => { + const source = packagedExemplar(); + const { dispatcher } = makeDispatcher([source]); + + const result = await dispatcher.handleAutomation(clonePath(source.name as string), 'POST', NEW, CTX); + expect(result.response?.status).toBe(200); + const clone = (result.response?.body?.data ?? result.response?.body)?.flow as Record; + + // The fixture is worth comparing in the first place. + const facetKeys = Object.keys(omit(source, [...FLOW_CLONE_MUTATED_FIELDS, ...FLOW_CLONE_DROPPED_KEYS])); + expect(facetKeys.length).toBeGreaterThanOrEqual(EXEMPLAR_FACET_FLOOR); + + // THE assertion. Not a spot-check of `nodes`/`edges` — an enumerated + // check is the very shape #11703 measured failing, so the comparison is + // whole-object or it is nothing. + expect(omit(clone, FLOW_CLONE_MUTATED_FIELDS)).toEqual( + omit(source, [...FLOW_CLONE_MUTATED_FIELDS, ...FLOW_CLONE_DROPPED_KEYS]), + ); + + // Stated a second way, so a failure NAMES the missing facet instead of + // printing two large objects side by side. + for (const key of facetKeys) { + expect(clone, `facet '${key}' was dropped by the clone`).toHaveProperty(key); + expect(clone[key], `facet '${key}' changed value`).toEqual(source[key]); + } + }); + + it('mutates exactly the three fields ADR-0126 §7.1 names, and no others', async () => { + const source = packagedExemplar(); + const { dispatcher } = makeDispatcher([source]); + + const result = await dispatcher.handleAutomation(clonePath(source.name as string), 'POST', NEW, CTX); + const clone = (result.response?.body?.data ?? result.response?.body)?.flow as Record; + + expect(clone.name).toBe(NEW.name); + expect(clone.label).toBe(NEW.label); + // `draft`: the schema's own default for something never deployed. It is + // NOT an off-switch — see `flow-clone.ts` and the notice assertion below. + expect(clone.status).toBe(FLOW_CLONE_STATUS); + expect(clone.status).not.toBe(source.status); + + // The set that differs is exactly the mutated set. + const differing = Object.keys(clone).filter((k) => JSON.stringify(clone[k]) !== JSON.stringify(source[k])); + expect(differing.sort()).toEqual([...FLOW_CLONE_MUTATED_FIELDS].sort()); + }); + + it('registers the clone under the NEW name, with the definition it returned', async () => { + const source = packagedExemplar(); + const { dispatcher, spies, flows } = makeDispatcher([source]); + + const result = await dispatcher.handleAutomation(clonePath(source.name as string), 'POST', NEW, CTX); + const clone = (result.response?.body?.data ?? result.response?.body)?.flow; + + expect(spies.registerFlow).toHaveBeenCalledTimes(1); + expect(spies.registerFlow).toHaveBeenCalledWith(NEW.name, clone); + expect(flows.get(NEW.name)).toEqual(clone); + // …and the source is still there, untouched. A clone is not a rename. + expect(flows.get(source.name as string)).toEqual(packagedExemplar()); + }); + + it('is a DEEP copy — editing the clone cannot reach back into the original', async () => { + // The source handed to `cloneFlowDefinition` is the engine's LIVE + // `FlowParsed` out of its flow map. A shallow spread would leave the two + // definitions sharing one `nodes` array, so the first edit to either + // would silently rewrite the other's automation. + const source = packagedExemplar(); + const { dispatcher } = makeDispatcher([source]); + + const result = await dispatcher.handleAutomation(clonePath(source.name as string), 'POST', NEW, CTX); + const clone = (result.response?.body?.data ?? result.response?.body)?.flow as any; + + clone.nodes[1].config.to = '{record.other_email}'; + clone.variables[0].defaultValue = 999; + clone.errorHandling.maxRetries = 99; + + expect((source as any).nodes[1].config.to).toBe('{record.manager_email}'); + expect((source as any).variables[0].defaultValue).toBe(30); + expect((source as any).errorHandling.maxRetries).toBe(3); + }); +}); + +describe('#12156 — no ancestry is recorded anywhere (amendment ruling 2, §9)', () => { + it('drops the source package envelope: the clone is org-owned, not a second copy of the package', async () => { + const source = packagedExemplar(); + const { dispatcher } = makeDispatcher([source]); + + const result = await dispatcher.handleAutomation(clonePath(source.name as string), 'POST', NEW, CTX); + const clone = (result.response?.body?.data ?? result.response?.body)?.flow as Record; + + // The exemplar really did carry them, so this is measuring a strip and + // not an absence. + for (const key of ['_packageId', '_provenance', '_lock']) { + expect(source, `fixture no longer carries '${key}'`).toHaveProperty(key); + } + for (const key of FLOW_CLONE_DROPPED_KEYS) { + expect(clone, `'${key}' survived onto the clone`).not.toHaveProperty(key); + } + // Which is the point: `_packageId` names the base's package, so keeping + // it would BE the ancestry ruling 2 forbids — and would leave the clone + // locked by the package the admin cloned to get out from under. + expect(JSON.stringify(clone)).not.toContain('crm'); + }); + + it('records no provenance field on the definition and none on the response', async () => { + const source = packagedExemplar(); + const { dispatcher } = makeDispatcher([source]); + + const result = await dispatcher.handleAutomation(clonePath(source.name as string), 'POST', NEW, CTX); + const data = (result.response?.body?.data ?? result.response?.body) as Record; + const clone = data.flow as Record; + + // A response field is the cheapest place for ancestry to reappear, and + // a UI that reads one starts displaying a lineage the platform has ruled + // it does not track. + const forbidden = ['clonedFrom', 'cloned_from', 'source', 'sourceFlow', 'copiedFrom', 'basedOn', 'baseFlow', 'ancestor', 'origin']; + for (const key of forbidden) { + expect(data, `response carries ancestry key '${key}'`).not.toHaveProperty(key); + expect(clone, `definition carries ancestry key '${key}'`).not.toHaveProperty(key); + } + // Nothing anywhere in the response names the flow it was copied from. + expect(JSON.stringify(data)).not.toContain(source.name); + }); +}); + +describe('#12156 — the new machine name is mandatory', () => { + it('refuses a clone with no `name`, located on the field, before anything is registered', async () => { + const { dispatcher, spies } = makeDispatcher(); + + let thrown: any; + try { + await dispatcher.handleAutomation(clonePath('crm_opportunity_escalation'), 'POST', { label: 'Copy' }, CTX); + } catch (e) { + thrown = e; + } + expect(thrown, 'a clone with no `name` was accepted').toBeDefined(); + expect(validationFailureDetails(thrown)?.fields).toMatchObject([{ field: 'name', code: 'required' }]); + expect(spies.registerFlow).not.toHaveBeenCalled(); + }); + + it('refuses an empty or non-string `name`', async () => { + const { dispatcher, spies } = makeDispatcher(); + for (const name of ['', ' ', 42, null]) { + await expect( + dispatcher.handleAutomation(clonePath('crm_opportunity_escalation'), 'POST', { name, label: 'Copy' }, CTX), + JSON.stringify(name), + ).rejects.toThrow(); + } + expect(spies.registerFlow).not.toHaveBeenCalled(); + }); + + it('refuses a clone with no `label` — two flows sharing one display name are indistinguishable', async () => { + const { dispatcher, spies } = makeDispatcher(); + + let thrown: any; + try { + await dispatcher.handleAutomation(clonePath('crm_opportunity_escalation'), 'POST', { name: 'copy_flow' }, CTX); + } catch (e) { + thrown = e; + } + expect(thrown, 'a clone with no `label` was accepted').toBeDefined(); + expect(validationFailureDetails(thrown)?.fields).toMatchObject([{ field: 'label', code: 'required' }]); + expect(spies.registerFlow).not.toHaveBeenCalled(); + }); + + it('refuses an unknown key rather than silently ignoring it', async () => { + // The neighbour toggle route's #3899 posture: a key one letter off is a + // located 400, never a silent drop. `status` is the tempting one — a + // caller may well try to choose it, and it is exactly the field the ADR + // says the CLONE decides. + const { dispatcher, spies } = makeDispatcher(); + + let thrown: any; + try { + await dispatcher.handleAutomation( + clonePath('crm_opportunity_escalation'), + 'POST', + { ...NEW, status: 'active' }, + CTX, + ); + } catch (e) { + thrown = e; + } + expect(thrown).toBeDefined(); + expect(validationFailureDetails(thrown)?.fields).toMatchObject([{ field: 'status', code: 'unknown_field' }]); + expect(spies.registerFlow).not.toHaveBeenCalled(); + }); + + it('checks the body BEFORE the registry — a malformed body never triggers a lookup', async () => { + const { dispatcher, spies } = makeDispatcher(); + await expect( + dispatcher.handleAutomation(clonePath('definitely_not_a_flow'), 'POST', { name: 42 }, CTX), + ).rejects.toThrow(); + expect(spies.getFlow).not.toHaveBeenCalled(); + expect(spies.registerFlow).not.toHaveBeenCalled(); + }); +}); + +describe('#12156 — a same-name clone is refused loudly, naming the sanctioned path', () => { + it('answers 409 RESOURCE_CONFLICT when the target name is the source name', async () => { + const source = packagedExemplar(); + const { dispatcher, spies } = makeDispatcher([source]); + + const result = await dispatcher.handleAutomation( + clonePath(source.name as string), + 'POST', + { name: source.name, label: 'Another Escalation' }, + CTX, + ); + + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(FLOW_CLONE_NAME_TAKEN_STATUS); + expect(FLOW_CLONE_NAME_TAKEN_STATUS).toBe(409); + const error = result.response?.body?.error; + expect(result.response?.body?.success).toBe(false); + // ADR-0112: a semantic code AND a status, never the number alone. + expect(error?.code).toBe('RESOURCE_CONFLICT'); + expect(error?.httpStatus).toBe(409); + + // ⛔ Nothing was registered. "Refuse first, register second" is the only + // acceptable order here: the whole reason same-name is banned is that a + // second definition under one bare name silently shadows the first. + expect(spies.registerFlow).not.toHaveBeenCalled(); + }); + + it('answers 409 for ANY taken name, not only the source name', async () => { + const source = packagedExemplar(); + const occupant = { ...packagedExemplar(), name: 'acme_opportunity_escalation', label: 'Someone else' }; + const { dispatcher, spies, flows } = makeDispatcher([source, occupant]); + + const result = await dispatcher.handleAutomation(clonePath(source.name as string), 'POST', NEW, CTX); + + expect(result.response?.status).toBe(409); + expect(spies.registerFlow).not.toHaveBeenCalled(); + // The occupant is untouched — a clone never overwrites. + expect(flows.get(occupant.name)?.label).toBe('Someone else'); + }); + + it('the message names the offending name, the reason, and what to do instead', async () => { + const source = packagedExemplar(); + const { dispatcher } = makeDispatcher([source]); + + const result = await dispatcher.handleAutomation( + clonePath(source.name as string), + 'POST', + { name: source.name, label: 'Another Escalation' }, + CTX, + ); + const message: string = result.response?.body?.error?.message ?? ''; + + // Named, the way the body rejections name their offending key. + expect(message).toContain(source.name as string); + // The REASON — this is not a storage conflict, and a caller told only + // "already exists" will reasonably assume it is one and go hunting. + expect(message).toContain('shadow'); + expect(message).toContain('ADR-0126'); + // THE SANCTIONED PATH, which is what the card asks for: retry under a + // name no flow uses, spelled concretely enough to act on. + expect(message).toMatch(/Retry with a machine name no flow uses/); + expect(message).toContain(`${source.name}_copy`); + }); +}); + +describe('#12156 — the source, the notice, and the gate', () => { + it('cloning a flow that does not exist is 404, not 500 (#7535 posture)', async () => { + const { dispatcher, spies } = makeDispatcher(); + + const result = await dispatcher.handleAutomation(clonePath('definitely_not_a_flow'), 'POST', NEW, CTX); + + expect(result.response?.status).toBe(404); + expect(result.response?.body?.error?.code).toBe('RESOURCE_NOT_FOUND'); + expect(result.response?.body?.error?.message).toContain('definitely_not_a_flow'); + expect(spies.registerFlow).not.toHaveBeenCalled(); + }); + + it('states plainly that references are NOT re-pointed, and that the clone is armed', async () => { + const source = packagedExemplar(); + const { dispatcher } = makeDispatcher([source]); + + const result = await dispatcher.handleAutomation(clonePath(source.name as string), 'POST', NEW, CTX); + const notice: string = ((result.response?.body?.data ?? result.response?.body) as any)?.notice ?? ''; + + // ADR-0126 §9: automatic re-pointing is explicitly not chartered, and + // the ADR requires the surface to TELL the admin so. + expect(notice).toMatch(/not re-pointed/i); + // And the fact an admin is most likely to be caught by: `draft` is a + // lifecycle label, not an off-switch — the engine only disables on + // `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'); + }); + + it('is an authoring write — a caller without `manage_metadata` is refused 403, nothing registered', async () => { + // A clone REGISTERS flow metadata at environment scope, which is + // precisely the escalation #10145 measured and closed for `POST /`. + // An ungated clone door would reopen it, with the twist that the caller + // need not author a definition at all — it copies a trusted one. + const { dispatcher, spies } = makeDispatcher(); + const unentitled = { request: {}, executionContext: { userId: 'user_2', systemPermissions: [] } } as any; + + const result = await dispatcher.handleAutomation( + clonePath('crm_opportunity_escalation'), + 'POST', + NEW, + unentitled, + ); + + expect(result.response?.status).toBe(403); + expect(result.response?.body?.error?.code).toBe('PERMISSION_DENIED'); + // Refused BEFORE the service is consulted (#10145: nothing is + // registered or unregistered before the refusal). + expect(spies.getFlow).not.toHaveBeenCalled(); + expect(spies.registerFlow).not.toHaveBeenCalled(); + }); + + it('does not shadow the legacy execution door for a flow literally named `clone`', async () => { + // `POST /automation/trigger/clone` RUNS the flow named `clone`; the + // authoring gate excludes `parts[0] === 'trigger'` so that door is not + // over-blocked. Asserted here because the exclusion is invisible at the + // route and easy to drop when the gate is next edited. + const execute = vi.fn(async () => ({ success: true, runId: 'run_1' })); + const services: Record = { automation: { execute } }; + const resolve = (name: string) => services[name]; + const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; + const dispatcher = new HttpDispatcher(kernel); + const unentitled = { request: {}, executionContext: { userId: 'user_2', systemPermissions: [] } } as any; + + const result = await dispatcher.handleAutomation('/trigger/clone', 'POST', {}, unentitled); + + expect(result.response?.status).not.toBe(403); + expect(execute).toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index a037e92c0c..324706b666 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -30,6 +30,14 @@ import { isPausedRun, FLOW_NOT_FOUND_STATUS, } from '../flow-dispatch-status.js'; +// [#12156] ADR-0126 §7.1 clone — the whole-definition copy, the keys it must +// not carry forward, the same-name refusal and the references notice. +import { + cloneFlowDefinition, + flowCloneNameTakenMessage, + FLOW_CLONE_NAME_TAKEN_STATUS, + FLOW_CLONE_NOTICE, +} from '../flow-clone.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -367,6 +375,24 @@ function isFlowAuthoringWrite(parts: string[], method: string): boolean { // `/automation/trigger/toggle` RUNS that flow. Gating it would over-block // an execution door, which is the one thing the ruling did not do. if (method === 'POST' && parts[1] === 'toggle') return parts[0] !== 'trigger'; + // [#12156] `POST /automation/:name/clone` — the ADR-0126 §7.1 clone door. + // + // It CREATES a flow, so it belongs to this set for the same reason + // `POST /` does, and leaving it out would have been a bypass of the whole + // #10145 gate rather than a gap in it: the measured escalation there was a + // tenant org owner without `manage_metadata` registering flow metadata at + // ENVIRONMENT scope, and a clone door registers flow metadata at + // environment scope. An ungated clone reproduces that verbatim, with the + // extra twist that the caller does not even have to author a definition — + // it copies one the deployment already trusts. + // + // Matched exactly as the toggle arm above is, and for the identical + // reason: no upper bound on depth (the route arm tests `parts[1] === + // 'clone'` with no length check, so a gate spelled `parts.length === 2` + // would leave `/:name/clone/anything` ungated), and `parts[0] !== + // 'trigger'` so that `POST /automation/trigger/clone` — the LEGACY + // EXECUTION door for a flow literally named `clone` — is not over-blocked. + if (method === 'POST' && parts[1] === 'clone') return parts[0] !== 'trigger'; // `PUT /automation/:name` / `DELETE /automation/:name` — the update and // deregister doors. Exactly one segment: a deeper path is a run surface. if (method === 'PUT' || method === 'DELETE') return parts.length === 1; @@ -803,6 +829,15 @@ async function respondToFlowTrigger( * ⚑ authoring write — `manage_metadata` (#10243): * enablement is environment-wide, so an * unentitled toggle reached every organization + * POST /:name/clone → clone the whole definition under a NEW machine + * name (ADR-0126 §7.1, #12156). Body + * `{ name, label }`, both mandatory; unknown source + * → 404, target name already taken → 409 + * `RESOURCE_CONFLICT`. ⛔ No ancestry is recorded + * or returned (amendment ruling 2), and references + * are NOT re-pointed — the response says so (§9). + * ⚑ authoring write — `manage_metadata`: it + * registers flow metadata, like `POST /` * GET /:name/runs → listRuns (query: limit, cursor — validated, #7300; * status — validated AND honoured, #7359) * ⚑ run-state read — `sys_automation_run` grant (#7900) @@ -1168,6 +1203,120 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str } } + // POST /:name/clone → clone the whole definition under a new machine + // name (ADR-0126 §7.1). The copy itself, the fields it mutates, the + // keys it must not carry forward and the notice it returns all live in + // `../flow-clone.ts` — see that module's header for the ADR and for + // #11703, the measurement that decides the copy's SHAPE. + // + // Built out of `getFlow` + `registerFlow`, not a new contract method: + // `IAutomationService` lives in `packages/spec`, and this door needs + // nothing the contract does not already offer. The clone therefore goes + // through the engine's own registration path, so it is canonicalized + // and validated exactly as a create is (`registerFlow` → + // `canonicalizeStoredFlow` + `validateNodeConfigKeys` + + // `validateFlowExpressions`) rather than by a second policy that agrees + // with the first only until one of them moves. + if (parts[1] === 'clone' && m === 'POST') { + if (typeof automationService.registerFlow === 'function' && typeof automationService.getFlow === 'function') { + // BODY FIRST, registry second — #3899's guarantee on this + // domain is that nothing reaches the service until the body is + // legal, and the toggle arm above keeps it the same way. + const cloneBody = body ?? {}; + if (typeof cloneBody !== 'object' || Array.isArray(cloneBody)) { + throw validationFailure('Invalid clone body — expected { name: string, label: string }', [ + { field: '(body)', code: 'invalid_type', message: 'expected an object' }, + ]); + } + const unknownKeys = Object.keys(cloneBody).filter((k) => k !== 'name' && k !== 'label'); + if (unknownKeys.length > 0) { + throw validationFailure( + `Unknown key${unknownKeys.length > 1 ? 's' : ''} ${unknownKeys.map((k) => `\`${k}\``).join(', ')} — the clone body is { name: string, label: string }`, + // `unknown_field` — the ADR-0114 catalog member for "a + // key the target does not declare" (#8124). + unknownKeys.map((k) => ({ field: k, code: 'unknown_field', message: 'not a clone field — the clone body is { name, label }' })), + ); + } + // The NEW MACHINE NAME IS MANDATORY (ADR-0126 §7.1, the #11513 + // shape exactly). Refused here rather than defaulted, because + // every default a clone could pick is either the source's own + // name — the same-name clone the ADR bans outright — or a name + // this door invented on the admin's behalf and would then have + // to keep inventing consistently forever. + const targetName = (cloneBody as { name?: unknown }).name; + if (typeof targetName !== 'string' || targetName.trim() === '') { + throw validationFailure( + 'A clone requires a new machine name — `name` is mandatory (ADR-0126 §7.1). ' + + 'The clone is a sibling flow, not a revision of the one it was copied from.', + [{ field: 'name', code: targetName === undefined ? 'required' : 'invalid_type', message: 'expected a non-empty string' }], + ); + } + // `label` is mandatory too, mirroring the permission-set clone + // this action is shaped on (`sys-permission-set.object.ts`: + // both `label` and `name` are `required: true`). ADR-0126 §7.1 + // lists `label` among the three fields a clone mutates, and a + // clone that silently kept the source's display name would put + // two identically-labelled flows on the packaged-automation + // page (§7.4) with nothing to tell them apart. + const targetLabel = (cloneBody as { label?: unknown }).label; + if (typeof targetLabel !== 'string' || targetLabel.trim() === '') { + throw validationFailure( + 'A clone requires a new display name — `label` is mandatory. ' + + 'Two flows sharing one label are indistinguishable on the automation surface.', + [{ field: 'label', code: targetLabel === undefined ? 'required' : 'invalid_type', message: 'expected a non-empty string' }], + ); + } + + // The SOURCE must exist — the same existence probe `GET /:name` + // and the toggle arm use, so the three routes cannot disagree + // about which flows exist. 404, not 500: a typo'd source name + // is a caller mistake (#7535). + const source = await automationService.getFlow(name); + if (!source) { + return { handled: true, response: deps.error(flowNotFoundMessage(name), FLOW_NOT_FOUND_STATUS) }; + } + + // ⛔ SAME-NAME REFUSAL, loudly, naming the sanctioned path. + // Checked against the same probe, so "already exists" means the + // same thing here as everywhere else on this domain. This also + // catches `name === `, which is the case the ADR is + // actually about — see `flowCloneNameTakenMessage` for why a + // second definition under one bare name is a silent, + // order-dependent shadow rather than a storage error. + const collision = await automationService.getFlow(targetName); + if (collision) { + return { + handled: true, + response: deps.error(flowCloneNameTakenMessage(targetName), FLOW_CLONE_NAME_TAKEN_STATUS), + }; + } + + const clone = cloneFlowDefinition(source, { name: targetName, label: targetLabel }); + // Engine verdicts are answered as a 400, not rethrown — the + // create arm's reasoning (`flowDefinitionRefusal`) applies + // verbatim, and it matters more here: a clone that the engine + // refuses must not leave a half-registered flow behind, and it + // must not read as a server fault when the source definition is + // simply one this deployment can no longer register. + try { + automationService.registerFlow(targetName, clone); + } catch (e) { + return { + handled: true, + response: deps.errorFromThrown(flowDefinitionRefusal(e), VALIDATION_FAILED_STATUS), + }; + } + // ⛔ NO ANCESTRY on the way out either (ADR-0126 amendment + // ruling 2, §9): the response names the flow that was created + // and says nothing about what it was copied from. There is no + // `clonedFrom` key here on purpose — a response field is the + // cheapest place for ancestry to reappear, and a UI that reads + // one starts displaying a lineage the platform has ruled it + // does not track. + return { handled: true, response: deps.success({ flow: clone, notice: FLOW_CLONE_NOTICE }) }; + } + } + // POST /:name/runs/:runId/resume → resume a paused run (screen-flow // runtime / ADR-0019). Body `{ inputs }` = a screen node's collected // values, applied as bare flow variables; `output`/`branchLabel` also diff --git a/packages/runtime/src/flow-clone.ts b/packages/runtime/src/flow-clone.ts new file mode 100644 index 0000000000..913d62c749 --- /dev/null +++ b/packages/runtime/src/flow-clone.ts @@ -0,0 +1,230 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # Flow clone — whole-definition copy under a new machine name (ADR-0126 §7.1) + * + * The copy half of ADR-0126's packaged-metadata customization model, shaped on + * the landed permission-set clone (`sys-permission-set.object.ts`, #11513): an + * admin who cannot edit a packaged flow in place gets an ordinary, + * org-authored sibling to edit instead. + * + * Three properties of that ADR are load-bearing here, and each one is a rule + * this module exists to make mechanical rather than remembered. + * + * ## 1. WHOLE-DEFINITION COPY — ⛔ never param-list assembly + * + * {@link cloneFlowDefinition} copies the parsed definition and mutates exactly + * three fields. It does NOT enumerate the facets a flow has, and adding a facet + * to `FlowSchema` must never require an edit here. + * + * That is not stylistic. The permission-set clone this is shaped on assembles + * its payload from an enumerated param list, and #11703 measured what an + * enumerated list costs: three of the six facets (`system_permissions`, + * `row_level_security`, `tab_permissions`) were simply not listed, so cloning a + * set carrying system permissions or RLS produced a clone with NONE of them — + * created, success toast fired, difference discoverable only by diffing the two + * records. Fail-closed, and therefore quiet. + * + * A flow has far more facets than a permission set — `description`, + * `successMessage`/`errorMessage`, `version`, `type`, `variables`, `nodes`, + * `edges`, `runAs`, the retry/error-handling block, and whatever `FlowSchema` + * grows next — so the enumerated shape is not merely riskier here, it is + * unmaintainable. ADR-0126 §7.1 rules it out by name. + * + * `flow-clone.test.ts` asserts this as the #11703 counter-example: deep + * equality of the cloned definition against the source, minus the three + * mutated fields. A dropped facet fails that test rather than shipping. + * + * ## 2. NO ANCESTRY (amendment ruling 2) + * + * The clone is "an ordinary org/install-owned flow with no recorded + * relationship to what it was copied from"; ADR-0126 §9 records that clone + * provenance is deliberately NOT tracked — "no `cloned_from` column, no + * 'clone based on v3, base now v5' line". So this module mints no provenance + * field, and the route stamps none on the response. + * + * It also has the converse duty, which is the less obvious half: it must not + * carry the SOURCE's provenance forward. A packaged flow's parsed definition + * carries the ADR-0010 protection envelope — `_packageId`, `_packageVersion`, + * `_provenance: 'package'`, `_lock`, … — because `FlowSchema` spreads + * `MetadataProtectionFields` (`flow.zod.ts`). Copied verbatim onto a clone, + * those keys would: + * + * - record where the clone came from, which is exactly the ancestry ruling 2 + * forbids — `_packageId` names the base's package; + * - make the clone a PACKAGE artifact rather than an org-owned one, so + * package upgrade/uninstall would re-seed or remove the admin's own work; + * - carry the base's `_lock` onto the clone, leaving it as uneditable as the + * flow the admin cloned to get around — which defeats the entire feature; + * - and classify the clone as a code artifact to `isCodeArtifactBody` + * (ADR-0029 D9.6), the test the boot pull's flow precedence reads. + * + * So the envelope is dropped, and the drop is DERIVED from the spec's own + * declaration ({@link MetadataProtectionFields}) rather than restated as a + * literal list here — an envelope key added to the spec is stripped by this + * module the day it lands, with no edit and no second list to drift. + * + * ⚠️ This is the one place the implementation reads more into ADR-0126 than the + * card spelled out; it is flagged on the PR for the reviewer. Everything else + * below is the ADR verbatim. + * + * ## 3. REFERENCES ARE NOT RE-POINTED + * + * ADR-0126 §9: automatic re-pointing of references on clone is explicitly not + * chartered — no reference index exists (#11665 §3.2) — so "the clone's + * references stay pointed at what the original pointed at, and the surface + * tells the admin so". {@link FLOW_CLONE_NOTICE} is that sentence, returned on + * every successful clone so the fact is stated where the admin is standing + * rather than only in an ADR. + */ + +import { METADATA_READ_DECORATIONS, MetadataProtectionFields } from '@objectstack/spec/kernel'; + +/** + * The deployment status a clone is created with. + * + * `'draft'` is `FlowSchema`'s own default for a flow that has not been + * deployed, and it is the honest value for something that was created a + * moment ago and has never been reviewed. + * + * ⚠️ It does NOT make the clone inert, and nothing here should be read as + * claiming it does. The engine disables a flow on `status` `'obsolete'` or + * `'invalid'` only (`engine.ts` `registerFlow`); `'draft'` and `'active'` both + * stay enabled and both get their trigger bound, so a clone of a record-change + * flow starts firing on the same writes as its base. That is stated plainly in + * {@link FLOW_CLONE_NOTICE} instead of being papered over. + * + * ⛔ Deliberately not `'obsolete'`, tempting as an auto-off clone is: ADR-0126 + * §7.2 rules that clone and disable are INDEPENDENT primitives — + * "cloned-without-disabled and disabled-without-clone are both ordinary states + * the surface shows plainly, not halves of an unfinished ceremony". Folding a + * disable into the clone would be inventing the ceremony the ADR declined, and + * would also mean this action silently retires a flow the admin asked it to + * create. + */ +export const FLOW_CLONE_STATUS = 'draft' as const; + +/** + * The three fields a clone mutates — ADR-0126 §7.1, "mutates only + * `name`/`label`/`status`". + * + * Exported so the test asserts the mutation set from the same constant the + * implementation applies, rather than restating it (a second list here is the + * #11703 mechanism in miniature). + */ +export const FLOW_CLONE_MUTATED_FIELDS = ['name', 'label', 'status'] as const; + +/** + * Keys that must NOT survive onto a clone — the ADR-0010 protection envelope + * plus the read-time decorations. + * + * DERIVED from the spec, never restated: `MetadataProtectionFields` is the + * declaration `FlowSchema` itself spreads, and `METADATA_READ_DECORATIONS` is + * the canonical list of keys the metadata read path stamps onto a served + * document (which are not valid inputs to the schema that produced them — see + * `metadata-read-decorations.ts`; a served flow carrying `_diagnostics` is what + * broke the cold-boot flow bind in cloud#971). + * + * The read decorations would usually be absent here — a clone reads its source + * from the engine's flow map, not over `/meta` — but the strip costs nothing + * and closes the case where a caller's source came through a served read. + */ +export const FLOW_CLONE_DROPPED_KEYS: readonly string[] = Object.freeze([ + ...METADATA_READ_DECORATIONS, + ...Object.keys(MetadataProtectionFields), +]); + +/** + * What the clone response tells the admin, in the admin's own words. + * + * Two facts, both required to be stated rather than discovered: + * + * 1. References are not re-pointed (ADR-0126 §9). A cloned flow's subflow + * nodes, action calls and object references point exactly where the + * original's did. + * 2. The clone is armed. `status: 'draft'` is a lifecycle label, not an + * off-switch — see {@link FLOW_CLONE_STATUS}. An admin who clones a + * 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. + */ +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.'; + +/** ADR-0112 envelope for the same-name refusal: a status AND a code. */ +export const FLOW_CLONE_NAME_TAKEN_STATUS = 409; + +/** + * The same-name refusal, naming the sanctioned path. + * + * ADR-0126 §7.1 refuses a same-name clone outright, and the reason is worth + * carrying to the caller rather than answering a bare "conflict": storage + * legitimately holds both rows — the uniqueness index keys on + * `(type, name, organization_id, COALESCE(package_id, ''))` (ADR-0005 + * amendment, #6825) — so nothing downstream stops the second definition from + * existing. What breaks is the engine, whose flow map is keyed by BARE name: + * the two definitions collapse into one slot and the survivor is decided by + * registration order. #11665 §2.2 measured it as a silent, non-deterministic + * replacement; #11997 tracks the shadow diagnostics for the case where it has + * already happened. + * + * The message therefore says what to do, not merely what went wrong — a clone + * dialog is the exact moment the admin is typing a name, so a refusal that + * does not name the remedy sends them looking for one. + */ +export function flowCloneNameTakenMessage(name: string): string { + return ( + `Flow '${name}' already exists — a clone must take a NEW machine name. ` + + 'Same-name clones are refused on purpose: the automation engine keys flows by bare ' + + 'name, so a second definition under one name silently shadows the other and which of ' + + 'the two actually dispatches depends on registration order (ADR-0126 §7.1). ' + + `Retry with a machine name no flow uses (for example '${suggestCloneName(name)}').` + ); +} + +/** + * A name suggestion for the refusal message. Purely advisory text — nothing + * reads it back, and the caller is free to ignore it. + * + * Kept inside the `^[a-z_][a-z0-9_]*$` shape `FlowSchema.name` requires, so the + * suggestion is one the caller can actually submit. + */ +function suggestCloneName(name: string): string { + return `${name}_copy`; +} + +/** + * Build the clone of `source` under `target`. + * + * Whole-definition copy: everything the source carries comes across, then + * exactly {@link FLOW_CLONE_MUTATED_FIELDS} are set and + * {@link FLOW_CLONE_DROPPED_KEYS} are removed. No facet is enumerated, so no + * facet can be forgotten (§1 above). + * + * DEEP copy, not a spread: the source is the engine's LIVE `FlowParsed` object + * out of its flow map, so a shallow copy would leave the clone sharing its + * `nodes`/`edges`/`variables` arrays with the flow it was copied from — and the + * first edit to either would silently rewrite the other. A parsed flow + * definition is JSON-shaped data (it is what `FlowSchema` produced from + * metadata), so `structuredClone` is total over it. + * + * The result is NOT validated here. It goes back through the engine's own + * `registerFlow`, which canonicalizes and validates it exactly as it does a + * create — one validation policy, not a second one that agrees today. + */ +export function cloneFlowDefinition( + source: unknown, + target: { name: string; label: string }, +): Record { + const copy = structuredClone(source) as Record; + for (const key of FLOW_CLONE_DROPPED_KEYS) delete copy[key]; + copy.name = target.name; + copy.label = target.label; + copy.status = FLOW_CLONE_STATUS; + return copy; +}