diff --git a/.changeset/28149-flow-clone-lands-in-package.md b/.changeset/28149-flow-clone-lands-in-package.md
new file mode 100644
index 00000000000..f01cabd28e3
--- /dev/null
+++ b/.changeset/28149-flow-clone-lands-in-package.md
@@ -0,0 +1,23 @@
+---
+'@objectstack/spec': minor
+'@objectstack/runtime': patch
+'@objectstack/client': patch
+'@object-ui/app-shell': patch
+---
+
+feat(runtime)!: the flow clone door is "new similar" — the copy lands switched off in a writable package, by default the source's paired `.custom`, never package-less (#28149)
+
+Clause-②: yes (narrowing) — `POST /api/v1/automation/:name/clone` accepts a new optional `package` member and answers a new `packageId` member (the copy's package, beside `flow` and `notice`), and a request it accepted before is now refused: a clone of a flow no read-only package ships, sent with no `package`, is refused `400` where it was written package-less.
+Metadata-protocol: added — `CloneFlowRequestSchema` and `CloneFlowResponseSchema` are new authorable API schemas in `@objectstack/spec/api` (the `authorable-surface/api.json` shard gains them), and the request's `package` member is a new key a caller can write. No existing key is removed, refused or changed in meaning.
+
+
+
+**BREAKING** request narrowing at the flow clone door (`@objectstack/runtime`'s `/automation` domain), per ADR-0146 D3/D4.
+
+- **Before.** The clone door wrote the copy as a package-less environment row with `status: 'draft'`, which the engine arms like `active`, so a copy of a record-triggered flow ran beside its source as soon as it was saved.
+- **Now.** The copy lands in a writable package and is written switched off (`status: 'obsolete'`, the value Studio's Enabled switch writes for Off). With no `package`, the target is the source package's paired customization package `.custom`, ensured through the protocol's ensure door (the one creation path of the pair). With `package`, the target must be installed and writable (`422 WRITABLE_PACKAGE_REQUIRED` otherwise) and depend on the source's package (`409 RESOURCE_CONFLICT` otherwise). Every refusal is answered before anything is written. The save is a first write, so a name the target package already holds is refused `409 RESOURCE_CONFLICT` instead of overwriting it. The answer is `{ flow, packageId, notice }`, and the notice says the copy is switched off.
+- **Unchanged.** The whole-definition copy, the mandatory new name and label, the credential refusal, the walled-environment refusal and the absence of clone lineage.
+- **New surface.** `CloneFlowRequestSchema` and `CloneFlowResponseSchema` (`@objectstack/spec/api`), the SDK's `client.automation.clone(name, { name, label, package? })`, and the route ledger row now `sdk` with both schemas named. Setup's "Open the clone in Studio" link opens the copy in the package the answer names.
+- **Who sees it.** A client that clones a flow authored in the deployment (or in a writable package) without naming a package; and anything that read the copy's `status` as `draft`.
+
+**The remedy.** Name the writable package the copy lands in with `package`. To have the copy run, switch it on (`POST /api/v1/automation/:name/toggle`, body `{enabled: true}`).
diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx
index ab3bd328ce1..cc99db9e390 100644
--- a/content/docs/concepts/metadata-lifecycle.mdx
+++ b/content/docs/concepts/metadata-lifecycle.mdx
@@ -178,7 +178,7 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/
| Type | Sanctioned path the refusal names |
| :--- | :--- |
-| `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. |
+| `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. The copy is written switched off, into the source package's paired customization package `.custom` (created on first use), or into the writable package the body's optional `package` names; a flow no read-only package ships has no pair, so its clone names `package`. The answer's `packageId` says where the copy landed ([ADR-0146](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md) D3, D4). |
| `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. |
| `permission` | Clone it under a new name: the "Clone" action on the permission set, or the clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label, package?}`) with a new name. The clone door copies the whole definition from the security catalog; a copy of a packaged set lands in the package's paired `.custom` (ADR-0146 D3/D6). |
diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx
index 92f41072c52..af076bdc954 100644
--- a/content/docs/references/api/automation-api.mdx
+++ b/content/docs/references/api/automation-api.mdx
@@ -41,6 +41,7 @@ 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 a packaged flow
+POST /api/v1/automation/:name/clone — Copy a flow, switched off, into a package
GET /api/v1/automation/:name/runs — List execution runs
GET /api/v1/automation/:name/runs/:runId — Get single execution run
```
@@ -52,8 +53,8 @@ GET /api/v1/automation/:name/runs/:runId — Get single execution run
## TypeScript Usage
```typescript
-import { AutomationApiErrorCode, AutomationFlowPathParamsSchema, AutomationRunPathParamsSchema, CreateFlowRequestSchema, CreateFlowResponseSchema, DeleteFlowRequestSchema, DeleteFlowResponseSchema, FlowFailureDetailsSchema, FlowInnerRefusalSchema, GetFlowRequestSchema, GetFlowResponseSchema, GetRunRequestSchema, GetRunResponseSchema, ListRunsRequestSchema, ListRunsResponseSchema, ResumeFailureDetailsSchema, ToggleFlowRequestSchema, ToggleFlowResponseSchema, TriggerFlowRequestSchema, TriggerFlowResponseSchema, UpdateFlowRequestSchema, UpdateFlowResponseSchema } from '@objectstack/spec/api';
-import type { AutomationApiErrorCode, AutomationFlowPathParams, AutomationRunPathParams, CreateFlowRequest, CreateFlowResponse, DeleteFlowRequest, DeleteFlowResponse, FlowFailureDetails, FlowInnerRefusal, GetFlowRequest, GetFlowResponse, GetRunRequest, GetRunResponse, ListRunsRequest, ListRunsResponse, ResumeFailureDetails, ToggleFlowRequest, ToggleFlowResponse, TriggerFlowRequest, TriggerFlowResponse, UpdateFlowRequest, UpdateFlowResponse } from '@objectstack/spec/api';
+import { AutomationApiErrorCode, AutomationFlowPathParamsSchema, AutomationRunPathParamsSchema, CloneFlowRequestSchema, CloneFlowResponseSchema, CreateFlowRequestSchema, CreateFlowResponseSchema, DeleteFlowRequestSchema, DeleteFlowResponseSchema, FlowFailureDetailsSchema, FlowInnerRefusalSchema, GetFlowRequestSchema, GetFlowResponseSchema, GetRunRequestSchema, GetRunResponseSchema, ListRunsRequestSchema, ListRunsResponseSchema, ResumeFailureDetailsSchema, ToggleFlowRequestSchema, ToggleFlowResponseSchema, TriggerFlowRequestSchema, TriggerFlowResponseSchema, UpdateFlowRequestSchema, UpdateFlowResponseSchema } from '@objectstack/spec/api';
+import type { AutomationApiErrorCode, AutomationFlowPathParams, AutomationRunPathParams, CloneFlowRequest, CloneFlowResponse, CreateFlowRequest, CreateFlowResponse, DeleteFlowRequest, DeleteFlowResponse, FlowFailureDetails, FlowInnerRefusal, GetFlowRequest, GetFlowResponse, GetRunRequest, GetRunResponse, ListRunsRequest, ListRunsResponse, ResumeFailureDetails, ToggleFlowRequest, ToggleFlowResponse, TriggerFlowRequest, TriggerFlowResponse, UpdateFlowRequest, UpdateFlowResponse } from '@objectstack/spec/api';
// Validate data
const result = AutomationApiErrorCode.parse(data);
@@ -99,6 +100,64 @@ const result = AutomationApiErrorCode.parse(data);
| **runId** | `string` | ✅ | Execution run ID |
+---
+
+## CloneFlowRequest
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **name** | `string` | ✅ | The copy's machine name — mandatory, and a name no flow uses (ADR-0126 §7.1): the automation engine keys flows by bare name, so a taken name is refused 409 `RESOURCE_CONFLICT`. |
+| **label** | `string` | ✅ | The copy's display name — mandatory, so the copy and its source are told apart in every list. |
+| **package** | `string` | optional | Id of the package the copy lands in. Given, it must be an installed, writable package whose dependency closure holds the source's package: an unknown or read-only one is refused 422 `WRITABLE_PACKAGE_REQUIRED`, one that declares no dependency path to the source's package 409 `RESOURCE_CONFLICT`, and nothing is written. Omitted, the copy lands in the source's paired customization package `.custom`, created on first use; a source no read-only package ships has no pair, and the request is refused 400 `VALIDATION_FAILED` located on this member. |
+
+
+---
+
+## CloneFlowResponse
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **success** | `boolean` | ✅ | Operation success status |
+| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false |
+| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata |
+| **data** | `{ flow: object; packageId: string; notice: string }` | ✅ | |
+
+### Nested Shape: `CloneFlowResponse.error`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
+| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
+| **message** | `string` | ✅ | Readable error message |
+| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
+| **refusal** | `true` | optional | Producer-declared: the 5xx this envelope carries is a deliberate refusal whose `message` is authored for the caller, so a boundary that reads the declaration keeps it verbatim (until the withhold arms read it, a declared refusal is still withheld). Absent (the default) on a declared fault, whose `message` is withheld from the body and logged for the operator; redundant on a 4xx. Presence is the declaration — `true` is the only value. |
+| **category** | `string` | optional | Error category (e.g. validation, authorization) |
+| **httpStatus** | `integer` | optional | HTTP status of the response carrying this error |
+| **details** | `any` | optional | Additional error context (e.g. field validation errors) |
+| **requestId** | `string` | optional | Request ID for tracking |
+
+### Nested Shape: `CloneFlowResponse.meta`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **timestamp** | `string` | ✅ | |
+| **duration** | `integer` | optional | Server-side processing duration in milliseconds |
+| **requestId** | `string` | optional | |
+| **traceId** | `string` | optional | |
+
+### Nested Shape: `CloneFlowResponse.data`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **flow** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The copy as stored: switched off, in `packageId`, with no lineage. |
+| **packageId** | `string` | ✅ | The package the copy landed in: the one the request named, else the source's paired customization package `.custom`. |
+| **notice** | `string` | ✅ | One line for the administrator, in the server's words: the copy is switched off until someone switches it on, and its references are not re-pointed. |
+
+
---
## CreateFlowRequest
diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx
index 18999f91906..d918783a56c 100644
--- a/content/docs/references/index.mdx
+++ b/content/docs/references/index.mdx
@@ -1,7 +1,7 @@
---
title: Protocol reference — every schema by module
navTitle: Protocol Reference
-description: Every schema published by @objectstack/spec — 1582 schemas across 14 protocol modules
+description: Every schema published by @objectstack/spec — 1584 schemas across 14 protocol modules
---
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with
| Module | Pages | Schemas | Description |
| :--- | ---: | ---: | :--- |
| [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
-| [API Protocol](/docs/references/api) | 34 | 458 | REST contracts, endpoints, routing, realtime, batch, discovery. |
+| [API Protocol](/docs/references/api) | 34 | 460 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
@@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 175 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
-| **Total** | **205** | **1582** | 14 protocol modules |
+| **Total** | **205** | **1584** | 14 protocol modules |
---
@@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations.
## API Protocol
-**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **34 pages, 458 schemas**
+**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **34 pages, 460 schemas**
REST contracts, endpoints, routing, realtime, batch, discovery.
@@ -73,7 +73,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery.
| [`app-bundle.zod.ts`](/docs/references/api/app-bundle) | `AppMetadataBundleResponse` |
| [`auth.zod.ts`](/docs/references/api/auth) | `AuthProvider`, `LoginRequest`, `LoginType`, `RefreshTokenRequest`, `RegisterRequest`, `Session`, `SessionResponse`, `SessionUser`, `UserProfileResponse` |
| [`auth-endpoints.zod.ts`](/docs/references/api/auth-endpoints) | `AuthEndpoint`, `AuthFeaturesConfig`, `AuthProviderInfo`, `DeviceRequestResponse`, `DeviceTokenResponse`, `EmailPasswordConfigPublic`, `GetAuthConfigResponse` |
-| [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowFailureDetails`, `FlowInnerRefusal`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` |
+| [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CloneFlowRequest`, `CloneFlowResponse`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowFailureDetails`, `FlowInnerRefusal`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` |
| [`batch.zod.ts`](/docs/references/api/batch) | `BatchConfig`, `BatchOperationResult`, `BatchOperationType`, `BatchOptions`, `BatchRecord`, `BatchUpdateRequest`, `BatchUpdateResponse`, `CrossObjectBatchDroppedFields`, `CrossObjectBatchOperation`, `CrossObjectBatchRequest`, `CrossObjectBatchResponse`, `DeleteManyRequest`, `UpdateManyRecord`, `UpdateManyRequest` |
| [`contract.zod.ts`](/docs/references/api/contract) | `ApiError`, `BaseResponse`, `BatchLoadingStrategy`, `BulkRequest`, `BulkResponse`, `CreateRequest`, `DataLoaderConfig`, `DeleteResponse`, `ExportRequest`, `IdRequest`, `ListRecordResponse`, `ModificationResult`, `QueryOptimizationConfig`, `RecordData`, `SingleRecordResponse`, `UpdateRequest` |
| [`discovery.zod.ts`](/docs/references/api/discovery) | `ApiRoutes`, `AuthFamilies`, `CapabilityDescriptor`, `Discovery`, `DiscoveryEnvironment`, `EnvironmentType`, `RouteHealthEntry`, `RouteHealthReport`, `ServiceInfo`, `ServiceSelfInfo`, `ServiceStatus`, `WellKnownCapabilities` |
diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md
index 69617925ef6..6f0f8431d55 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
| Dir | Sites |
|---|---|
-| `api/` | 472 |
+| `api/` | 474 |
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index 78f6488c8d6..027ab0283d1 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -130,6 +130,8 @@ import {
ReassignOrphanedMetadataResponse,
DuplicatePackageResponse,
EnsureCustomizationPackageResponse,
+ CloneFlowRequest,
+ CloneFlowResponse,
ValidateDataIssue,
} from '@objectstack/spec/api';
// [#17536] The element the two `/packages` READ doors are declared to serve.
@@ -5889,6 +5891,40 @@ export class ObjectStackClient {
return this.unwrapResponse(res);
},
+ /**
+ * [#28149, ADR-0146 D4] "New similar": copy the flow `name` under a NEW
+ * machine name, into a package, switched off.
+ *
+ * `POST /automation/:name/clone` copies the whole definition, renames
+ * and relabels it, and writes it with `status: 'obsolete'`, so the copy
+ * never runs beside its original; switch it on by saving its
+ * definition with `status: 'active'` into its package and publishing.
+ * Its references are not re-pointed, and nothing records what it was
+ * copied from (ADR-0126 §9).
+ *
+ * `body.package` names the package the copy lands in: an installed,
+ * writable package whose dependency closure holds the source's
+ * package. Omitted, the copy lands in the source's paired
+ * customization package `.custom`, created on first use through
+ * the ensure door (`packages.ensureCustomization`). The answer names the
+ * package in `packageId`.
+ *
+ * Refused, with nothing written: an unknown source (404); a taken name
+ * (409 `RESOURCE_CONFLICT`); a source holding a credential (409); an
+ * unknown or read-only `package` (422 `WRITABLE_PACKAGE_REQUIRED`), or
+ * one whose closure lacks the source's package (409
+ * `RESOURCE_CONFLICT`); `package` omitted for a source no package ships,
+ * which has no pair (400). Requires `manage_metadata`.
+ */
+ clone: async (name: string, body: CloneFlowRequest): Promise => {
+ const route = this.getRoute('automation');
+ const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(name)}/clone`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ });
+ return this.unwrapResponse(res);
+ },
+
/**
* Execution run history
*/
diff --git a/packages/qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts b/packages/qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts
index d42abd4f180..419f1615a7b 100644
--- a/packages/qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts
+++ b/packages/qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts
@@ -45,6 +45,8 @@ const SOURCE = 'crm_convert_lead_wizard';
/** A customer-owned machine name no shipped flow uses. */
const CLONE = 'crm_convert_lead_wizard_clone_20676';
const CLONE_LABEL = 'Convert Lead (clone)';
+/** [#28149] The CRM app's paired customization package (`.custom`, ADR-0146 D3). */
+const CRM_PAIR = 'com.example.crm.custom';
interface ErrorBody { success?: boolean; error?: { code?: string; httpStatus?: number } }
@@ -76,7 +78,7 @@ describe('#20676 — POST /automation/:name/clone is mounted on the dispatcher b
expect(readBack.status).toBe(404);
});
- it('a legal clone answers 200 with the flow and FLOW_CLONE_NOTICE, and the clone reads back', async () => {
+ it('a legal clone answers 200 with the flow, its package and FLOW_CLONE_NOTICE, and the clone reads back', async () => {
// Control: the target does not exist before the clone, so the read-back
// below is evidence of THIS request rather than of the fixture.
const before = await stack.apiAs(adminToken, 'GET', `/automation/${CLONE}`);
@@ -90,11 +92,14 @@ describe('#20676 — POST /automation/:name/clone is mounted on the dispatcher b
expect(res.status, `legal clone: ${text}`).toBe(200);
const body = (await res.json()) as {
success?: boolean;
- data?: { flow?: { name?: string; label?: string; status?: string }; notice?: string };
+ data?: { flow?: { name?: string; label?: string; status?: string }; packageId?: string; notice?: string };
};
expect(body.success).toBe(true);
expect(body.data?.notice).toBe(FLOW_CLONE_NOTICE);
- expect(body.data?.flow).toMatchObject({ name: CLONE, label: CLONE_LABEL, status: 'draft' });
+ // [#28149, ADR-0146 D4] "New similar": switched off, in the CRM's paired
+ // customization package, which the clone ensured on first use.
+ expect(body.data?.flow).toMatchObject({ name: CLONE, label: CLONE_LABEL, status: 'obsolete' });
+ expect(body.data?.packageId).toBe(CRM_PAIR);
const readBack = await stack.apiAs(adminToken, 'GET', `/automation/${CLONE}`);
const readText = await readBack.clone().text();
diff --git a/packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts b/packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts
index bb3e9e3a6d4..74b95ccde01 100644
--- a/packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts
+++ b/packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts
@@ -300,8 +300,14 @@ describe('[#20790] flow credentials live in the write-only channel, not the stor
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});
- const plain = await stack.apiAs(token, 'POST', '/automation/zz_channel_plain_src/clone', { name: 'zz_channel_plain_copy', label: 'Copy' });
- expect(plain.status).toBe(200);
+ // [#28149, ADR-0146 D4] No package ships this source, so it has no paired
+ // customization package: its copy names the writable package it lands in.
+ const pkg = await stack.apiAs(token, 'POST', '/packages', { id: 'com.dogfood.channel-plain', name: 'Channel plain', version: '1.0.0', type: 'app' });
+ expect(pkg.status, await pkg.clone().text()).toBeLessThan(300);
+ const plain = await stack.apiAs(token, 'POST', '/automation/zz_channel_plain_src/clone', {
+ name: 'zz_channel_plain_copy', label: 'Copy', package: 'com.dogfood.channel-plain',
+ });
+ expect(plain.status, await plain.clone().text()).toBe(200);
});
it('7 — with no crypto provider a save carrying a credential is refused before anything is written', async () => {
diff --git a/packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts b/packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts
index 27b9721cbbd..77a1a9030ee 100644
--- a/packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts
+++ b/packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts
@@ -70,6 +70,8 @@ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
/** A package the showcase composition really loads — the claim a caller must not be able to make. */
const REAL_PACKAGE = 'com.example.showcase';
const ASSERTION = { _packageId: REAL_PACKAGE, _provenance: 'package' };
+/** [#28149] The showcase's paired customization package — where a clone of its flow lands. */
+const PAIR_PACKAGE = `${REAL_PACKAGE}.custom`;
const definition = (name: string) => ({
name,
@@ -271,13 +273,22 @@ describe('a flow\'s package provenance is the server\'s fact at every door (show
expect(put.status, JSON.stringify(put.json)).toBe(200);
});
- it('a clone of a shipped flow is saved as a tenant row that reads back on the metadata door', async () => {
+ it('a clone of a shipped flow is saved as a row of the base\'s pair that reads back on the metadata door', async () => {
const res = await call('POST', `/automation/${SHIPPED}/clone`, { name: CLONE, label: 'Dogfood clone' });
expect(res.status, JSON.stringify(res.json)).toBe(200);
+ // [#28149, ADR-0146 D3/D4] The copy lands in the base's paired
+ // customization package, never package-less.
+ expect(dataOf(res.json)?.packageId).toBe(PAIR_PACKAGE);
const served = await metaRead(CLONE);
expect(served.status, JSON.stringify(served.doc)).toBe(200);
- expect(isCodeArtifactBody(served.doc)).toBe(false);
+ // MEASURED, as for META_BOUND above: the served body surfaces the row's
+ // binding as a package stamp, which reads as code-shipped on its own…
+ expect(served.doc?._packageId).toBe(PAIR_PACKAGE);
+ expect(isCodeArtifactBody(served.doc)).toBe(true);
+ // …and the copy is still the customer's to edit: its round trip saves.
+ const echoed = await call('PUT', `/meta/flow/${CLONE}?package=${PAIR_PACKAGE}`, served.doc);
+ expect(echoed.status, JSON.stringify(echoed.json)).toBe(200);
// The clone is the customer's: the activation door refuses it too.
const off = await call('POST', `/automation/${CLONE}/toggle`, { enabled: false });
expect(off.status, JSON.stringify(off.json)).toBe(409);
diff --git a/packages/runtime/src/domains/automation-flow-clone-credential.test.ts b/packages/runtime/src/domains/automation-flow-clone-credential.test.ts
index b011e6e20c4..1cc2e0a1f58 100644
--- a/packages/runtime/src/domains/automation-flow-clone-credential.test.ts
+++ b/packages/runtime/src/domains/automation-flow-clone-credential.test.ts
@@ -56,7 +56,18 @@ function makeDispatcher(
}),
};
if (holdings) spies.flowCredentialHoldings = vi.fn(holdings);
- const services: Record = { automation: spies };
+ // [#28149] The door writes every copy into a package (ADR-0146 D4): the
+ // loader's set names a base, the ensure door answers its pair, and the
+ // registry holds both, so the one case that clones reaches its save.
+ const protocol = {
+ packagedArtifactOwner: () => 'base',
+ ensureCustomizationPackage: async () => ({ packageId: 'base.custom' }),
+ saveMetaItem: vi.fn(async () => ({ success: true })),
+ tenantAuthoredWriteRefusal: async () => undefined,
+ };
+ const packages: Record = { base: { id: 'base' }, 'base.custom': { id: 'base.custom', dependencies: { base: '^1' } } };
+ const objectql = { registry: { getPackage: (id: string) => (packages[id] ? { manifest: packages[id] } : undefined) } };
+ const services: Record = { automation: spies, protocol, objectql };
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 };
diff --git a/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts b/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts
new file mode 100644
index 00000000000..a336ca25e93
--- /dev/null
+++ b/packages/runtime/src/domains/automation-flow-clone-custom-package.test.ts
@@ -0,0 +1,341 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#28149, ADR-0146 D4] `POST /automation/:name/clone` is "new similar": the
+ * copy lands in a writable PACKAGE — by default the source's paired
+ * customization package `.custom`, ensured through the protocol's ensure
+ * door — and is written switched off, so it never runs beside its original.
+ *
+ * ## What was measured before this card
+ *
+ * The copy was saved naming no package (a package-less row, the scope ADR-0070
+ * D5/D6 retired) with `status: 'draft'`, which the automation engine arms like
+ * `active`: on showcase at `d6c0a75f1a`, `showcase_urgent_task_alert` and its
+ * `_copy` both ran on one record change. The first describe block below is
+ * that measurement as a test: it reds on the base tree (no `packageId` on the
+ * answer, a package-less row, `status: 'draft'`).
+ *
+ * ## The rig
+ *
+ * A REAL store and protocol: better-sqlite3 `:memory:`, the real `ObjectQL`
+ * engine with the real `sys_metadata*` objects, a real
+ * `ObjectStackProtocolImplementation` over its `SchemaRegistry`, so the ensure
+ * door, the package binding of the saved row and every save-door refusal are
+ * production's. The base is an installed app of a platform scope (read-only),
+ * and its flow is registered as the loader registers a package's flow, so
+ * `packagedArtifactOwner` — the loader's set — names the base. The automation
+ * engine is a map, as in the sibling door suites (its validation is not the
+ * subject); "switched off" is read where the engine reads it, the copy's
+ * `status`, and `engine.ts` disables on `obsolete` (its own suite pins that).
+ * `OS_HOME` is redirected because the ensure door really installs the pair.
+ *
+ * Only the envelope (`code` + `status`), the package bindings read back from
+ * the store, and the named package are asserted — never a refusal's sentence.
+ */
+
+import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { ObjectQL } from '@objectstack/objectql';
+import { SqlDriver } from '@objectstack/driver-sql';
+import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
+import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata';
+import { CloneFlowRequestSchema, CloneFlowResponseSchema } from '@objectstack/spec/api';
+import { ROUTE_LEDGER } from '../route-ledger.js';
+import { HttpDispatcher } from '../http-dispatcher.js';
+import { validationFailureDetails, VALIDATION_FAILED_STATUS } from '../validation-failure.js';
+import { FLOW_CLONE_NOTICE, FLOW_CLONE_STATUS } from '../flow-clone.js';
+
+let home: string;
+let priorHome: string | undefined;
+beforeAll(() => {
+ priorHome = process.env.OS_HOME;
+ home = mkdtempSync(join(tmpdir(), 'os-flow-clone-custom-'));
+ process.env.OS_HOME = home;
+});
+afterAll(() => {
+ if (priorHome === undefined) delete process.env.OS_HOME;
+ else process.env.OS_HOME = priorHome;
+ rmSync(home, { recursive: true, force: true });
+});
+
+/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */
+const TEST_PACKAGE_ID = 'objectstack-test';
+/** The base: an installed app the customer cannot author into (a platform scope). */
+const BASE = { id: 'com.acme.crm', name: 'CRM', version: '3.4.1', type: 'app', namespace: 'crm', scope: 'system' };
+const PAIR = 'com.acme.crm.custom';
+/** A writable package that depends on the base: a legal explicit target. */
+const EXT = { id: 'com.acme.ext', name: 'Ext', version: '1.0.0', type: 'module', namespace: 'ext', dependencies: { [BASE.id]: '^3' } };
+/** A writable package with no dependency on the base: outside the closure. */
+const OTHER = { id: 'com.acme.other', name: 'Other', version: '1.0.0', type: 'app', namespace: 'other' };
+/** A read-only package that depends on the base. */
+const SEALED = { id: 'com.acme.sealed', name: 'Sealed', version: '1.0.0', type: 'module', namespace: 'sealed', scope: 'system', dependencies: { [BASE.id]: '^3' } };
+
+/** The base's flow, as the loader registered it. */
+const SOURCE = 'crm_lead_followup';
+/** A flow authored in the deployment: no package ships it. */
+const LOCAL = 'local_digest';
+
+const AUTHOR = { userId: 'u_admin', isSystem: false, systemPermissions: ['manage_metadata'] };
+
+const flowOf = (name: string, extra: Record = {}) => ({
+ name, label: `Flow ${name}`, type: 'autolaunched', status: 'active', nodes: [], edges: [], ...extra,
+});
+
+const liveEngines: ObjectQL[] = [];
+afterEach(async () => {
+ while (liveEngines.length) {
+ try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
+ }
+});
+
+interface Answer {
+ status: number | undefined;
+ code: unknown;
+ data: any;
+ body: any;
+}
+
+async function boot() {
+ const engine = new ObjectQL();
+ liveEngines.push(engine);
+ engine.registerDriver(new SqlDriver({
+ client: 'better-sqlite3',
+ connection: { filename: ':memory:' },
+ useNullAsDefault: true,
+ }), true);
+ await engine.init();
+ engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID);
+ engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID);
+ engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID);
+ await engine.syncSchemas();
+ (engine.registry as unknown as { logLevel: string }).logLevel = 'silent';
+ engine.registry.installPackage(BASE as any, 'marketplace');
+ engine.registry.installPackage(EXT as any, 'code');
+ engine.registry.installPackage(OTHER as any, 'code');
+ engine.registry.installPackage(SEALED as any, 'marketplace');
+ // The base's flow, registered as the loader registers a package's flow.
+ const shipped = { ...flowOf(SOURCE), _packageId: BASE.id, _packageVersion: BASE.version, _provenance: 'package' };
+ engine.registry.registerItem('flow', shipped as any, 'name' as any, BASE.id);
+
+ const published: unknown[] = [];
+ const services = new Map([
+ ['package', { publish: async ({ manifest }: { manifest: unknown }) => { published.push(manifest); return { success: true }; } }],
+ ]);
+ const protocol = new ObjectStackProtocolImplementation(engine as any, () => services);
+
+ const flows = new Map([[SOURCE, shipped], [LOCAL, flowOf(LOCAL)]]);
+ const registerFlow = vi.fn((name: string, definition: unknown) => { flows.set(name, definition); return definition; });
+ const unregisterFlow = vi.fn((name: string) => { flows.delete(name); });
+ const getFlow = vi.fn(async (name: string) => flows.get(name) ?? null);
+ const named: Record = {
+ protocol,
+ objectql: engine,
+ automation: { handlerReady: true, registerFlow, unregisterFlow, getFlow },
+ };
+ const get = (name: string) => named[name] ?? null;
+ const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (name: string) => get(name) };
+ const dispatcher = new HttpDispatcher(kernel);
+ (dispatcher as any).timedResolveExecutionContext = async () => ({ ...AUTHOR });
+
+ /**
+ * The door's answer. A body refusal is THROWN as a located validation
+ * failure (the transport renders it 400), as every body refusal on this
+ * door is; it is read back here as the status and code it carries.
+ */
+ const clone = async (source: string, body: Record): Promise => {
+ try {
+ const res = await dispatcher.dispatch('POST', `/automation/${source}/clone`, body, {}, { request: { headers: {} } } as any);
+ const json = res.response?.body;
+ return { status: res.response?.status, code: json?.error?.code, data: json?.data, body: json };
+ } catch (e: any) {
+ const fields = validationFailureDetails(e)?.fields;
+ if (!fields) throw e;
+ return { status: VALIDATION_FAILED_STATUS, code: e.code, data: undefined, body: { error: { code: e.code, fields } } };
+ }
+ };
+
+ /** Every stored row of a flow, read back from the store, by package binding. */
+ const rows = async (name: string): Promise> => {
+ const found = (await engine.find('sys_metadata', { where: { type: 'flow', name }, context: { isSystem: true } })) ?? [];
+ return [...found].map((row: any) => ({
+ package_id: row.package_id ?? null,
+ state: row.state,
+ item: JSON.parse(String(row.metadata)),
+ }));
+ };
+ const installed = () => engine.registry.getAllPackages().map((p: any) => p.manifest?.id ?? p.id).sort();
+
+ return { engine, protocol, clone, rows, installed, flows, registerFlow, published };
+}
+
+describe('[#28149] the default target: the copy lands in .custom, switched off', () => {
+ it('precondition — the loader\'s set names the base as the source\'s package', async () => {
+ const rig = await boot();
+ expect(rig.protocol.packagedArtifactOwner({ type: 'flow', name: SOURCE })).toBe(BASE.id);
+ expect(rig.installed()).not.toContain(PAIR);
+ }, 60_000);
+
+ it('ensures the pair through the ensure door and lands the copy there, naming it on the answer', async () => {
+ const rig = await boot();
+
+ const answer = await rig.clone(SOURCE, { name: 'acme_lead_followup', label: 'Lead follow-up (ACME)' });
+
+ expect(answer.status, JSON.stringify(answer.body)).toBe(200);
+ expect(answer.data.packageId).toBe(PAIR);
+ expect(rig.installed()).toContain(PAIR);
+ // ⛔ Never package-less: exactly one stored row, bound to the pair.
+ expect((await rig.rows('acme_lead_followup')).map((r) => r.package_id)).toEqual([PAIR]);
+ // The answer is the declared one.
+ expect(CloneFlowResponseSchema.safeParse(answer.body).success).toBe(true);
+ }, 60_000);
+
+ it('writes the copy switched off, stored and registered, and says so in the server\'s words', async () => {
+ const rig = await boot();
+
+ const answer = await rig.clone(SOURCE, { name: 'acme_lead_followup', label: 'Lead follow-up (ACME)' });
+
+ expect(answer.status, JSON.stringify(answer.body)).toBe(200);
+ expect(FLOW_CLONE_STATUS).toBe('obsolete');
+ expect(answer.data.flow.status).toBe('obsolete');
+ const [row] = await rig.rows('acme_lead_followup');
+ expect(row.item.status).toBe('obsolete');
+ expect(rig.flows.get('acme_lead_followup')?.status).toBe('obsolete');
+ expect(answer.data.notice).toBe(FLOW_CLONE_NOTICE);
+ // …and the original keeps running, untouched.
+ expect(rig.flows.get(SOURCE)?.status).toBe('active');
+ expect(await rig.rows(SOURCE)).toEqual([]);
+ }, 60_000);
+
+ it('carries no base envelope onto the copy: the row is the pair\'s own item', async () => {
+ const rig = await boot();
+
+ const answer = await rig.clone(SOURCE, { name: 'acme_lead_followup', label: 'Lead follow-up (ACME)' });
+
+ expect(answer.status, JSON.stringify(answer.body)).toBe(200);
+ for (const key of ['_packageId', '_packageVersion', '_provenance', '_lock']) {
+ expect(answer.data.flow, `answer carries '${key}'`).not.toHaveProperty(key);
+ }
+ const [row] = await rig.rows('acme_lead_followup');
+ expect(row.item._packageId).not.toBe(BASE.id);
+ expect(row.item._provenance).not.toBe('package');
+ }, 60_000);
+
+ it('a second clone reuses the pair the first one created (the ensure door is idempotent)', async () => {
+ const rig = await boot();
+
+ const first = await rig.clone(SOURCE, { name: 'acme_lead_followup', label: 'First copy' });
+ const pairsAfterFirst = rig.published.length;
+ const second = await rig.clone(SOURCE, { name: 'acme_lead_followup_two', label: 'Second copy' });
+
+ expect(first.status, JSON.stringify(first.body)).toBe(200);
+ expect(second.status, JSON.stringify(second.body)).toBe(200);
+ expect(second.data.packageId).toBe(PAIR);
+ expect(rig.installed().filter((id) => id === PAIR)).toHaveLength(1);
+ expect(rig.published.length, 'the second clone persisted another package').toBe(pairsAfterFirst);
+ expect((await rig.rows('acme_lead_followup_two')).map((r) => r.package_id)).toEqual([PAIR]);
+ }, 60_000);
+});
+
+describe('[#28149] an explicit target: `package`', () => {
+ it('a writable package that depends on the base takes the copy', async () => {
+ const rig = await boot();
+
+ const answer = await rig.clone(SOURCE, { name: 'ext_lead_followup', label: 'Lead follow-up (ext)', package: EXT.id });
+
+ expect(answer.status, JSON.stringify(answer.body)).toBe(200);
+ expect(answer.data.packageId).toBe(EXT.id);
+ expect((await rig.rows('ext_lead_followup')).map((r) => r.package_id)).toEqual([EXT.id]);
+ expect(answer.data.flow.status).toBe('obsolete');
+ // No pair is minted for an explicit target.
+ expect(rig.installed()).not.toContain(PAIR);
+ }, 60_000);
+
+ const refused: Array<[string, string, number, string]> = [
+ ['a read-only package', SEALED.id, 422, 'WRITABLE_PACKAGE_REQUIRED'],
+ ['a package that is not installed', 'com.acme.nowhere', 422, 'WRITABLE_PACKAGE_REQUIRED'],
+ ['a writable package outside the source\'s closure', OTHER.id, 409, 'RESOURCE_CONFLICT'],
+ ['the read-only base itself', BASE.id, 422, 'WRITABLE_PACKAGE_REQUIRED'],
+ ];
+ for (const [what, target, status, code] of refused) {
+ it(`${what} is refused ${status} ${code}, and nothing is registered, stored or minted`, async () => {
+ const rig = await boot();
+
+ const answer = await rig.clone(SOURCE, { name: 'refused_copy', label: 'Refused copy', package: target });
+
+ expect(answer.status, JSON.stringify(answer.body)).toBe(status);
+ expect(answer.code).toBe(code);
+ expect(answer.body.error.details?.packageId).toBe(target);
+ expect(rig.registerFlow).not.toHaveBeenCalled();
+ expect(await rig.rows('refused_copy')).toEqual([]);
+ expect(rig.installed()).not.toContain(PAIR);
+ }, 60_000);
+ }
+
+ it('a blank `package` is refused 400 on the field, never read as omitted', async () => {
+ const rig = await boot();
+
+ const answer = await rig.clone(SOURCE, { name: 'blank_target', label: 'Blank target', package: ' ' });
+
+ expect(answer.status, JSON.stringify(answer.body)).toBe(400);
+ expect(answer.body.error.fields).toMatchObject([{ field: 'package', code: 'invalid_type' }]);
+ expect(rig.registerFlow).not.toHaveBeenCalled();
+ expect(rig.installed()).not.toContain(PAIR);
+ }, 60_000);
+});
+
+describe('[#28149] a source no package ships has no pair', () => {
+ it('omitting `package` is refused 400 VALIDATION_FAILED on `package` — never a package-less copy', async () => {
+ const rig = await boot();
+
+ const answer = await rig.clone(LOCAL, { name: 'local_digest_copy', label: 'Digest copy' });
+
+ expect(answer.status, JSON.stringify(answer.body)).toBe(400);
+ expect(answer.code).toBe('VALIDATION_FAILED');
+ expect(answer.body.error.fields).toMatchObject([{ field: 'package', code: 'required' }]);
+ expect(rig.registerFlow).not.toHaveBeenCalled();
+ expect(await rig.rows('local_digest_copy')).toEqual([]);
+ }, 60_000);
+
+ it('naming a writable package lands it there, switched off', async () => {
+ const rig = await boot();
+
+ const answer = await rig.clone(LOCAL, { name: 'local_digest_copy', label: 'Digest copy', package: OTHER.id });
+
+ expect(answer.status, JSON.stringify(answer.body)).toBe(200);
+ expect(answer.data.packageId).toBe(OTHER.id);
+ expect((await rig.rows('local_digest_copy')).map((r) => r.package_id)).toEqual([OTHER.id]);
+ expect(answer.data.flow.status).toBe('obsolete');
+ }, 60_000);
+});
+
+describe('[#28149] the contract: the ledger row names the schemas this suite parses with', () => {
+ it('the clone row is SDK surface and names CloneFlowRequestSchema / CloneFlowResponseSchema', () => {
+ const row = ROUTE_LEDGER.find((e) => e.route === 'POST /automation/:name/clone');
+ expect(row, 'the clone row must exist in ROUTE_LEDGER').toBeDefined();
+ expect(row!.disposition).toBe('sdk');
+ expect(row!.client).toBe('automation.clone');
+ expect(row!.requestSchema).toBe('CloneFlowRequestSchema');
+ expect(row!.responseSchema).toBe('CloneFlowResponseSchema');
+ });
+
+ it('each declared member reaches the door as sent, and an undeclared one is refused on its key', async () => {
+ expect(Object.keys(CloneFlowRequestSchema.shape).sort()).toEqual(['label', 'name', 'package']);
+ const rig = await boot();
+
+ const sent = { name: 'ext_sent', label: 'Sent as is', package: EXT.id };
+ expect(CloneFlowRequestSchema.safeParse(sent).success).toBe(true);
+ const landed = await rig.clone(SOURCE, sent);
+ expect(landed.status, JSON.stringify(landed.body)).toBe(200);
+ expect(landed.data.flow).toMatchObject({ name: sent.name, label: sent.label });
+ expect(landed.data.packageId).toBe(sent.package);
+
+ const undeclared = { name: 'ext_status', label: 'Chooses its status', status: 'active' };
+ expect(CloneFlowRequestSchema.safeParse(undeclared).success).toBe(false);
+ const refused = await rig.clone(SOURCE, undeclared);
+ expect(refused.status).toBe(400);
+ expect(refused.body.error.fields).toMatchObject([{ field: 'status', code: 'unknown_field' }]);
+ expect(await rig.rows('ext_status')).toEqual([]);
+ }, 60_000);
+});
diff --git a/packages/runtime/src/domains/automation-flow-clone.test.ts b/packages/runtime/src/domains/automation-flow-clone.test.ts
index 7b5a3c9a03e..80e80c67af9 100644
--- a/packages/runtime/src/domains/automation-flow-clone.test.ts
+++ b/packages/runtime/src/domains/automation-flow-clone.test.ts
@@ -147,14 +147,46 @@ function makeDispatcher(seed: Record[] = [packagedExemplar()])
flows.set(name, definition as Record);
}),
};
- const services: Record = { automation: spies };
+ const services: Record = { automation: spies, ...packageLanding() };
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 };
+ return { dispatcher: new HttpDispatcher(kernel), spies, flows, protocol: services.protocol as PackageLandingProtocol };
+}
+
+/** The base the exemplar's loader-set owner names, and its paired customization package. */
+const BASE_PACKAGE = 'crm';
+const PAIR_PACKAGE = 'crm.custom';
+
+type PackageLandingProtocol = ReturnType['protocol'];
+
+/**
+ * [#28149] Where the copy lands. The door now writes every copy into a
+ * package (ADR-0146 D4), so the rig carries the protocol members it asks —
+ * the loader's set naming the exemplar's base, the ensure door answering its
+ * pair, and the save — plus the `objectql` registry the target rule reads.
+ * The real protocol and registry are exercised in
+ * `automation-flow-clone-custom-package.test.ts`; here they are the minimum
+ * that lets each case reach the behaviour it is named after.
+ */
+function packageLanding() {
+ const manifests: Record }> = {
+ [BASE_PACKAGE]: { id: BASE_PACKAGE },
+ [PAIR_PACKAGE]: { id: PAIR_PACKAGE, dependencies: { [BASE_PACKAGE]: '^3' } },
+ };
+ const protocol = {
+ packagedArtifactOwner: vi.fn(() => BASE_PACKAGE),
+ ensureCustomizationPackage: vi.fn(async ({ basePackageId }: { basePackageId: string }) => ({
+ basePackageId, packageId: `${basePackageId}.custom`, namespace: 'crm_custom', dependencyRange: '^3', created: false, writable: true,
+ })),
+ saveMetaItem: vi.fn(async () => ({ success: true })),
+ tenantAuthoredWriteRefusal: vi.fn(async () => undefined),
+ };
+ const objectql = { registry: { getPackage: (id: string) => (manifests[id] ? { manifest: manifests[id] } : undefined) } };
+ return { protocol, objectql };
}
/** Drop `keys` from a shallow copy — used to state "everything except …". */
@@ -204,9 +236,10 @@ describe('#12156 — whole-definition copy (the #11703 counter-example as a test
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.
+ // [#28149, ADR-0146 D4] `obsolete`: the copy is written switched off,
+ // so it never runs beside its original — see `flow-clone.ts`.
expect(clone.status).toBe(FLOW_CLONE_STATUS);
+ expect(FLOW_CLONE_STATUS).toBe('obsolete');
expect(clone.status).not.toBe(source.status);
// The set that differs is exactly the mutated set.
@@ -216,7 +249,7 @@ describe('#12156 — whole-definition copy (the #11703 counter-example as a test
it('registers the clone under the NEW name, with the definition it returned', async () => {
const source = packagedExemplar();
- const { dispatcher, spies, flows } = makeDispatcher([source]);
+ const { dispatcher, spies, flows, protocol } = 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;
@@ -224,6 +257,12 @@ describe('#12156 — whole-definition copy (the #11703 counter-example as a test
expect(spies.registerFlow).toHaveBeenCalledTimes(1);
expect(spies.registerFlow).toHaveBeenCalledWith(NEW.name, clone);
expect(flows.get(NEW.name)).toEqual(clone);
+ // [#28149] …and saved into the source's paired customization package,
+ // never package-less, as a first write.
+ expect(protocol.saveMetaItem).toHaveBeenCalledTimes(1);
+ expect(protocol.saveMetaItem).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'flow', name: NEW.name, packageId: PAIR_PACKAGE, parentVersion: null,
+ }));
// …and the source is still there, untouched. A clone is not a rename.
expect(flows.get(source.name as string)).toEqual(packagedExemplar());
});
@@ -442,24 +481,23 @@ describe('#12156 — the source, the notice, and the gate', () => {
expect(spies.registerFlow).not.toHaveBeenCalled();
});
- it('states in one line that the clone runs alongside the original, and that references are NOT re-pointed', async () => {
+ it('states in one line that the copy is switched off, and that references are NOT re-pointed', 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 ?? '';
- // 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).toContain('runs alongside the original until you switch one of them off');
+ // [#28149, ADR-0146 D4] "The copy is written inactive … and the
+ // response says so": the copy does not run until someone switches it
+ // on, stated in the server's words so no console restates it.
+ expect(notice).toContain('switched off and does not run until you switch it on');
// ADR-0126 §9: automatic re-pointing is explicitly not chartered, and
// the ADR requires the surface to TELL the admin so.
expect(notice).toContain('still uses the same subflows, actions and objects as the original');
- // [#27886] One line in business words. The console shows it beside a
- // "Switch the original off" button, so the engine words and the switch
- // locations stay in `flow-clone.ts`'s TSDoc.
+ // [#27886] One line in business words. The console shows it verbatim,
+ // so the engine words and the switch locations stay in
+ // `flow-clone.ts`'s TSDoc.
expect(notice.match(/[.!?](\s|$)/g)).toHaveLength(1);
expect(notice).not.toMatch(/\b(draft|status|off-switch|lifecycle|Studio)\b/i);
});
diff --git a/packages/runtime/src/domains/automation-flow-door-package-address.test.ts b/packages/runtime/src/domains/automation-flow-door-package-address.test.ts
index 599b75ec1df..21fc176e33e 100644
--- a/packages/runtime/src/domains/automation-flow-door-package-address.test.ts
+++ b/packages/runtime/src/domains/automation-flow-door-package-address.test.ts
@@ -145,6 +145,8 @@ async function boot() {
const getFlow = vi.fn(async (name: string) => flows.get(name) ?? null);
const services: Record = {
protocol,
+ // [#28149] The registry the clone door's target rule reads.
+ objectql: engine,
automation: { handlerReady: true, registerFlow, unregisterFlow, getFlow },
};
const get = (name: string) => services[name] ?? null;
@@ -305,7 +307,9 @@ for (const order of [[ALPHA, BETA], [BETA, ALPHA]] as const) {
rig.flows.set('source_flow', flowOf('source_flow', 'Source'));
const before = await rig.rows(SHARED);
- const answer = await rig.call('POST', '/automation/source_flow/clone', { name: SHARED, label: 'The clone' });
+ // [#28149] A source no package ships names the package its copy
+ // lands in; one of the two packages holding the name.
+ const answer = await rig.call('POST', '/automation/source_flow/clone', { name: SHARED, label: 'The clone', package: ALPHA });
expect(answer).toEqual({ status: 409, code: 'RESOURCE_CONFLICT' });
expect(await rig.rows(SHARED)).toEqual(before);
@@ -373,7 +377,10 @@ for (const order of [[ALPHA, BETA], [BETA, ALPHA]] as const) {
rig.flows.set('source_flow', flowOf('source_flow', 'Source'));
const before = await rig.rows(SHARED);
- const answer = await rig.refusal('POST', '/automation/source_flow/clone', { name: SHARED, label: 'The clone' });
+ // [#28149] Into one of the two packages holding the name: the
+ // clone's first-write pin meets that package's row, and the answer
+ // is the same taken-name refusal.
+ const answer = await rig.refusal('POST', '/automation/source_flow/clone', { name: SHARED, label: 'The clone', package: ALPHA });
expect({ status: answer.status, code: answer.code }).toEqual({ status: 409, code: 'RESOURCE_CONFLICT' });
// A clone takes a NEW name (ADR-0126 §7.1): no package route is prescribed.
@@ -383,10 +390,10 @@ for (const order of [[ALPHA, BETA], [BETA, ALPHA]] as const) {
.filter((candidate) => candidate !== SHARED);
expect(suggested).toHaveLength(1);
- const followed = await rig.call('POST', '/automation/source_flow/clone', { name: suggested[0], label: 'The clone' });
+ const followed = await rig.call('POST', '/automation/source_flow/clone', { name: suggested[0], label: 'The clone', package: ALPHA });
expect(followed).toEqual({ status: 200, code: undefined });
- expect(await rig.bindings(suggested[0]!)).toEqual([null]);
+ expect(await rig.bindings(suggested[0]!)).toEqual([ALPHA]);
expect(await rig.rows(SHARED)).toEqual(before);
}, 60_000);
diff --git a/packages/runtime/src/domains/automation-flow-save-actor.test.ts b/packages/runtime/src/domains/automation-flow-save-actor.test.ts
index a95a628259a..e25a4376ab1 100644
--- a/packages/runtime/src/domains/automation-flow-save-actor.test.ts
+++ b/packages/runtime/src/domains/automation-flow-save-actor.test.ts
@@ -36,6 +36,12 @@ import type { HttpProtocolContext } from '../http-dispatcher.js';
/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */
const TEST_PACKAGE_ID = 'objectstack-test';
const SOURCE = 'customer_source_flow';
+/**
+ * [#28149] A clone lands in a writable package, never package-less. SOURCE is
+ * authored in the deployment, so no package ships it and it has no paired
+ * `.custom`: its copy names this installed, writable package.
+ */
+const TARGET = { id: 'com.acme.workspace', name: 'Workspace', version: '1.0.0', type: 'app', namespace: 'ws' };
const definitionOf = (name: string, label: string) => ({
name,
@@ -78,6 +84,7 @@ async function boot() {
engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID);
engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID);
await engine.syncSchemas();
+ engine.registry.installPackage(TARGET as any, 'code');
const protocol = new ObjectStackProtocolImplementation(engine as any);
const flows = new Map([[SOURCE, definitionOf(SOURCE, 'Source')]]);
@@ -88,7 +95,7 @@ async function boot() {
getFlow: async (name: string) => flows.get(name) ?? null,
listFlows: async () => [...flows.keys()],
};
- const services: Record = { protocol, automation };
+ const services: Record = { protocol, automation, objectql: engine };
const resolve = (name: string): unknown => services[name];
const kernel: any = { getService: resolve, getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve } };
@@ -123,7 +130,7 @@ describe('[#27414] the /automation definition doors record the user who saved',
it('POST /:name/clone records the admin who cloned on the copy', async () => {
const h = await boot();
const result = await h.dispatcher.handleAutomation(
- `/${SOURCE}/clone`, 'POST', { name: 'my_copy', label: 'My Copy' }, author('u_admin'), undefined,
+ `/${SOURCE}/clone`, 'POST', { name: 'my_copy', label: 'My Copy', package: TARGET.id }, author('u_admin'), undefined,
);
expect(statusOf(result)).toBe(200);
expect(await h.recorded('my_copy')).toEqual({ created_by: 'u_admin', updated_by: 'u_admin', recorded_by: ['u_admin'] });
@@ -135,7 +142,7 @@ describe('[#27414] the /automation definition doors record the user who saved',
expect(statusOf(await h.dispatcher.handleAutomation('/sys_flow', 'PUT', { definition: definitionOf('sys_flow', 'v2') }, SYSTEM(), undefined))).toBe(200);
expect(await h.recorded('sys_flow')).toEqual({ created_by: null, updated_by: null, recorded_by: [null, null] });
- expect(statusOf(await h.dispatcher.handleAutomation(`/${SOURCE}/clone`, 'POST', { name: 'sys_copy', label: 'System Copy' }, SYSTEM(), undefined))).toBe(200);
+ expect(statusOf(await h.dispatcher.handleAutomation(`/${SOURCE}/clone`, 'POST', { name: 'sys_copy', label: 'System Copy', package: TARGET.id }, SYSTEM(), undefined))).toBe(200);
expect(await h.recorded('sys_copy')).toEqual({ created_by: null, updated_by: null, recorded_by: [null] });
}, 60_000);
});
diff --git a/packages/runtime/src/domains/automation-packaged-base-lock.test.ts b/packages/runtime/src/domains/automation-packaged-base-lock.test.ts
index 33bd133c09a..b37c0e611ad 100644
--- a/packages/runtime/src/domains/automation-packaged-base-lock.test.ts
+++ b/packages/runtime/src/domains/automation-packaged-base-lock.test.ts
@@ -51,6 +51,8 @@ import type { HttpProtocolContext } from '../http-dispatcher.js';
/** A flow a code package ships — registered with a package id, as an artifact loader does. */
const PACKAGED = 'pkg_alert_flow';
const PACKAGE_ID = 'com.example.pkg';
+/** [#28149] The base's paired customization package — where a clone lands. */
+const PAIR_PACKAGE_ID = 'com.example.pkg.custom';
/** The customer's own flow — lives in the engine only (authored through `POST /automation`). */
const CUSTOMER = 'customer_flow';
/** A runtime-authored registry item with no package provenance. */
@@ -127,8 +129,17 @@ function boot(
environmentId,
);
+ // [#28149] The installed packages the clone door's target rule reads: the
+ // code-shipped base (read-only) and its writable pair, which depends on it.
+ const manifests: Record }> = {
+ [PACKAGE_ID]: { id: PACKAGE_ID, scope: 'system' },
+ [PAIR_PACKAGE_ID]: { id: PAIR_PACKAGE_ID, dependencies: { [PACKAGE_ID]: '*' } },
+ };
+ const objectql = { registry: { getPackage: (id: string) => (manifests[id] ? { manifest: manifests[id] } : undefined) } };
+
const services: Record = {
automation: { handlerReady: true, registerFlow, unregisterFlow, toggleFlow, getFlow },
+ objectql,
};
if (mode === 'real') services.protocol = protocol;
const resolve = (name: string): unknown => services[name];
@@ -350,11 +361,20 @@ describe('what the lock leaves open', () => {
// protocol's own save — pinned in
// `automation-tenant-authored-write.test.ts`; stood in for here, where
// the store is not this file's subject.
- vi.spyOn(h.protocol, 'saveMetaItem').mockResolvedValue({ success: true } as never);
+ const save = vi.spyOn(h.protocol, 'saveMetaItem').mockResolvedValue({ success: true } as never);
+ // [#28149] The copy lands in the source's paired `.custom`,
+ // ensured through N-13's door — stood in for here (its own suite pins
+ // it); the pair's manifest depends on the base, as the ensure door
+ // writes it.
+ vi.spyOn(h.protocol, 'ensureCustomizationPackage').mockResolvedValue(
+ { basePackageId: PACKAGE_ID, packageId: PAIR_PACKAGE_ID } as never,
+ );
const { response } = await h.dispatcher.handleAutomation(
`/${PACKAGED}/clone`, 'POST', { name: 'my_alert_copy', label: 'My Alert' }, AUTHOR(), undefined,
);
expect(statusOf(response)).toBe(200);
+ expect((response as any)?.body?.data?.packageId).toBe(PAIR_PACKAGE_ID);
+ expect(save).toHaveBeenCalledWith(expect.objectContaining({ packageId: PAIR_PACKAGE_ID }));
expect(h.registerFlow).toHaveBeenCalledWith('my_alert_copy', expect.objectContaining({ name: 'my_alert_copy' }));
expect(h.held(PACKAGED)).toEqual(definitionOf(PACKAGED));
});
diff --git a/packages/runtime/src/domains/automation-tenant-authored-write.test.ts b/packages/runtime/src/domains/automation-tenant-authored-write.test.ts
index d8cadf556b5..637dfc4e319 100644
--- a/packages/runtime/src/domains/automation-tenant-authored-write.test.ts
+++ b/packages/runtime/src/domains/automation-tenant-authored-write.test.ts
@@ -38,10 +38,13 @@ import { isCodeArtifactBody } from '@objectstack/metadata-core';
import { FlowSchema } from '@objectstack/spec/automation';
import { HttpDispatcher } from '../http-dispatcher.js';
+import { FLOW_CLONE_STATUS } from '../flow-clone.js';
import type { HttpProtocolContext } from '../http-dispatcher.js';
const PACKAGED = 'pkg_alert_flow';
const PACKAGE_ID = 'com.example.pkg';
+/** [#28149] The base's paired customization package — where a clone of its flow lands. */
+const PAIR_PACKAGE_ID = 'com.example.pkg.custom';
const CUSTOMER = 'customer_flow';
/** A tenant flow whose stored row predates the save door's `protection` refusal. */
const LEGACY = 'legacy_locked_flow';
@@ -86,10 +89,23 @@ function boot() {
);
const saveMetaItem = vi.spyOn(protocol, 'saveMetaItem').mockResolvedValue({ success: true } as never);
const rule = vi.spyOn(protocol, 'tenantAuthoredWriteRefusal');
+ // [#28149] A clone lands in a writable package: by default the source's
+ // paired `.custom`, ensured through N-13's door (its own suite pins
+ // it). The installed packages the door's target rule reads: the base
+ // (read-only) and its pair, which depends on it.
+ vi.spyOn(protocol, 'ensureCustomizationPackage').mockResolvedValue(
+ { basePackageId: PACKAGE_ID, packageId: PAIR_PACKAGE_ID } as never,
+ );
+ const manifests: Record }> = {
+ [PACKAGE_ID]: { id: PACKAGE_ID, scope: 'system' },
+ [PAIR_PACKAGE_ID]: { id: PAIR_PACKAGE_ID, dependencies: { [PACKAGE_ID]: '*' } },
+ };
+ const objectql = { registry: { getPackage: (id: string) => (manifests[id] ? { manifest: manifests[id] } : undefined) } };
const services: Record = {
automation: { handlerReady: true, registerFlow, unregisterFlow, getFlow },
protocol,
+ objectql,
};
const resolve = (name: string): unknown => services[name];
const kernel = { getService: resolve, getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve } };
@@ -155,7 +171,7 @@ describe('[#20761] the /automation definition-write doors apply the one authorin
});
describe('[#20761, ADR-0126 §7.1] the clone door saves its copy as a tenant row', () => {
- it('a clone of a shipped flow is registered AND saved through the protocol, env-wide, with no provenance', async () => {
+ it('a clone of a shipped flow is registered AND saved through the protocol, env-wide, into its pair, with no provenance', async () => {
const h = boot();
const { response } = await h.dispatcher.handleAutomation(
`/${PACKAGED}/clone`, 'POST', { name: 'my_alert_copy', label: 'My Alert' }, AUTHOR(), undefined,
@@ -167,7 +183,8 @@ describe('[#20761, ADR-0126 §7.1] the clone door saves its copy as a tenant row
const saved = h.saveMetaItem.mock.calls[0]![0] as { type: string; name: string; item: Record; organizationId?: unknown; packageId?: unknown };
expect(saved).toMatchObject({ type: 'flow', name: 'my_alert_copy' });
expect(saved.organizationId).toBeUndefined();
- expect(saved.packageId).toBeUndefined();
+ // [#28149] Never package-less: the copy is saved into the base's pair.
+ expect(saved.packageId).toBe(PAIR_PACKAGE_ID);
expect(isCodeArtifactBody(saved.item)).toBe(false);
expect(Object.keys(saved.item).filter((k) => k === '_packageId' || k === '_provenance')).toEqual([]);
// The copy was judged by the one rule, and admitted.
@@ -203,7 +220,9 @@ describe('[#20761, ADR-0126 §7.1] the clone door saves its copy as a tenant row
* that constant has to turn this red.
*/
describe('[#23120] a clone does not carry its source\'s stored `protection` block into the save', () => {
- const COPY = { name: 'legacy_locked_flow_copy', label: 'Legacy copy' };
+ // [#28149] Neither source is shipped by a package, so it has no pair: the
+ // copy names the writable package it lands in.
+ const COPY = { name: 'legacy_locked_flow_copy', label: 'Legacy copy', package: PAIR_PACKAGE_ID };
const MUTATED = ['name', 'label', 'status'];
const without = (body: Record, keys: readonly string[]) => {
const copy = { ...body };
@@ -239,6 +258,6 @@ describe('[#23120] a clone does not carry its source\'s stored `protection` bloc
expect(statusOf(response)).toBe(200);
expect(h.saveMetaItem).toHaveBeenCalledTimes(1);
const saved = h.saveMetaItem.mock.calls[0]![0] as { item: Record };
- expect(saved.item).toEqual({ ...definitionOf(CUSTOMER), name: COPY.name, label: COPY.label, status: 'draft' });
+ expect(saved.item).toEqual({ ...definitionOf(CUSTOMER), name: COPY.name, label: COPY.label, status: FLOW_CLONE_STATUS });
});
});
diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts
index ebb57ee6bc8..509d0dd0af5 100644
--- a/packages/runtime/src/domains/automation.ts
+++ b/packages/runtime/src/domains/automation.ts
@@ -73,6 +73,10 @@ import {
FLOW_CLONE_NAME_TAKEN_STATUS,
FLOW_CLONE_NOTICE,
} from '../flow-clone.js';
+// [#28149, ADR-0146 D4] The clone body's declaration (its closed member set)
+// and the target rule every "new similar" door shares.
+import { CloneFlowRequestSchema } from '@objectstack/spec/api';
+import { newSimilarTargetRefusal, newSimilarTargetRefusalAnswer } from './new-similar-target.js';
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
import { readDispatchedBody } from '../deferred-request-body.js';
@@ -2217,6 +2221,16 @@ function flowPackageRefusalMessage(verb: 'save' | 'delete', name: string, packag
+ `packages (${shown}${more}), and a ${verb} that names none has ${single}.`;
}
+/**
+ * [#28149] Whether a store refusal is the ADR-0008 first-write pin's: a save
+ * that asserted "no current row" (`parentVersion: null`) met one. Read from
+ * the ADR-0112 envelope (`409 METADATA_CONFLICT`), never from the sentence.
+ */
+function isFirstWriteConflict(refusal: unknown): boolean {
+ const envelope = refusal as { code?: unknown; status?: unknown; statusCode?: unknown } | null | undefined;
+ return envelope?.code === 'METADATA_CONFLICT' && (envelope.status ?? envelope.statusCode) === 409;
+}
+
/**
* [#23501] The refusal a definition door answers for a write the store refused:
* the store's own, as it was thrown, unless {@link flowPackagesRefusingWrite}
@@ -2241,6 +2255,14 @@ async function flowWriteRefusal(
name: string,
door: 'definition' | 'clone' | 'delete',
): Promise {
+ // [#28149] The clone's first-write pin met a row the target package
+ // already holds under the new name: the name is taken, so the clone door's
+ // own taken-name refusal, with its remedy (a name no flow uses).
+ if (door === 'clone' && isFirstWriteConflict(refusal)) {
+ return Object.assign(new Error(flowCloneNameTakenMessage(name)), {
+ code: 'RESOURCE_CONFLICT', status: FLOW_CLONE_NAME_TAKEN_STATUS,
+ });
+ }
const packageIds = await flowPackagesRefusingWrite(store, refusal, name);
if (!packageIds) return refusal;
const [message, status] = door === 'clone'
@@ -2314,6 +2336,12 @@ async function registerAndSaveFlow(
name: string,
definition: unknown,
door: 'definition' | 'clone',
+ /**
+ * [#28149] The package the clone door resolved for its copy
+ * ({@link resolveCloneTarget}) — the one caller here that holds a package
+ * of its own choosing rather than a stamp. The definition doors pass none.
+ */
+ packageId?: string,
): Promise<{ registered: unknown } | { refused: HttpDispatcherResult }> {
const store: (Partial>
& Partial>) | undefined =
@@ -2337,7 +2365,21 @@ async function registerAndSaveFlow(
// with no user passes no actor, an unattributed write as before.
const { userId: actor } = activationCaller(context);
try {
- await store.saveMetaItem({ type: FLOW_METADATA_TYPE, name, item: definition, ...(actor ? { actor } : {}) });
+ await store.saveMetaItem({
+ type: FLOW_METADATA_TYPE,
+ name,
+ item: definition,
+ // [#28149] The clone CREATES: its save carries the first-write
+ // pin (`parentVersion: null`, "no current row"), so a row the
+ // target package already holds under the new name — one the
+ // engine's collision probe cannot see (a row that failed to
+ // register, or one stored and never bound) — is refused, never
+ // overwritten by the copy. Answered as the taken-name refusal
+ // ({@link flowWriteRefusal}).
+ ...(door === 'clone' ? { parentVersion: null } : {}),
+ ...(packageId !== undefined ? { packageId } : {}),
+ ...(actor ? { actor } : {}),
+ });
} catch (e) {
restoreFlowRegistration(automationService, name, held);
return {
@@ -2348,6 +2390,95 @@ async function registerAndSaveFlow(
return { registered };
}
+/**
+ * [#28149, ADR-0146 D3/D4] WHERE the clone door's copy lands — "new similar":
+ * a package, always (ADR-0070 D5/D6 retired the package-less scope, and ⛔ no
+ * clone is written without one), resolved before anything is registered.
+ *
+ * - **`package` sent** — that package, judged by the target rule every "new
+ * similar" door shares ({@link newSimilarTargetRefusal}): installed,
+ * writable, and holding the source's package in its dependency closure.
+ * - **`package` omitted** — the source's paired customization package
+ * `.custom`, where `` is the package the loader's set says
+ * ships the source (`packagedArtifactOwner` — the loader's fact, ⛔ never
+ * the `_packageId` stamp on the definition the engine holds, which for a
+ * name two packages hold is the boot bind's pick). The pair is ensured
+ * through the protocol's `ensureCustomizationPackage` — the body of
+ * `POST /packages/:id/ensure-customization`, the ONE creation path of the
+ * pair (ADR-0146 D3), called in process and ⛔ never re-implemented. This
+ * door already holds the authority that door asks (`manage_metadata`, then
+ * the walled refusal, both judged before the domain is entered), so the
+ * call creates nothing a caller could not create there. Its refusals (a
+ * writable base, a base with no pair namespace, a namespace another package
+ * owns) are relayed as the protocol built them. The pair it answers is then
+ * judged by the same target rule, so a pair that is not writable here is
+ * refused before the copy is written into it.
+ * - **`package` omitted, and no package ships the source** — refused
+ * `400 VALIDATION_ERROR` on `package`: a flow authored in the deployment,
+ * or one stored in a writable package, has no base and so no pair, and
+ * this door does not guess one (its stored binding is not the engine's to
+ * tell, see {@link registerAndSaveFlow}). The caller names the package.
+ *
+ * A composition whose protocol cannot ensure a pair or save into a package
+ * has nowhere to land a copy: `501`, never the package-less write it used to
+ * make.
+ *
+ * Ordering: the pair is ensured before the copy is registered and saved, so a
+ * save the store then refuses leaves the pair in place. That is the pair's
+ * ordinary state — ensure is idempotent and an empty pair is a valid one —
+ * and the next clone reuses it.
+ */
+async function resolveCloneTarget(
+ deps: DomainHandlerDeps,
+ context: HttpProtocolContext,
+ sourceName: string,
+ requested: string | undefined,
+): Promise<{ packageId: string } | { refused: HttpDispatcherResult }> {
+ const protocol: Partial> | undefined =
+ await deps.resolveServiceOrLoud(context, 'protocol');
+ if (typeof protocol?.ensureCustomizationPackage !== 'function'
+ || typeof protocol.packagedArtifactOwner !== 'function'
+ || typeof protocol.saveMetaItem !== 'function') {
+ return {
+ refused: {
+ handled: true,
+ response: deps.error(
+ 'Flow clone not supported: a copy lands in a package, and this deployment has no metadata '
+ + 'protocol that can ensure a customization package and save a flow into it.',
+ 501,
+ ),
+ },
+ };
+ }
+ const subject = `flow '${sourceName}'`;
+ const owner = protocol.packagedArtifactOwner({ type: FLOW_METADATA_TYPE, name: sourceName });
+ let target: string;
+ if (requested !== undefined) {
+ target = requested;
+ } else {
+ if (owner === undefined) {
+ throw validationFailure(
+ `Flow '${sourceName}' is not shipped by a package, so it has no paired customization package to `
+ + 'land a copy in (ADR-0146 D3): name the package the copy lands in with `package`.',
+ [{ field: 'package', code: 'required', message: 'the source has no paired customization package' }],
+ );
+ }
+ try {
+ target = (await protocol.ensureCustomizationPackage({ basePackageId: owner })).packageId;
+ } catch (e) {
+ return { refused: { handled: true, response: deps.errorFromThrown(e) } };
+ }
+ }
+ const refusal = newSimilarTargetRefusal(await deps.getObjectQL(context), {
+ target,
+ subject,
+ ...(owner !== undefined ? { sourcePackageId: owner } : {}),
+ });
+ if (refusal) return { refused: newSimilarTargetRefusalAnswer(deps, refusal) };
+ return { packageId: target };
+}
+
/** The engine methods {@link unregisterAndDeleteFlow} drives. */
type FlowRemovalService =
Required> & Pick;
@@ -3537,13 +3668,18 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
{ field: '(body)', code: 'invalid_type', message: 'expected an object' },
]);
}
- const unknownKeys = Object.keys(cloneBody).filter((k) => k !== 'name' && k !== 'label');
+ // [#28149] The closed member set is the declaration's
+ // (`CloneFlowRequestSchema`, `@objectstack/spec/api`), read off
+ // it rather than re-listed, so the door and the contract cannot
+ // disagree about which keys a clone reads.
+ const declaredKeys = new Set(Object.keys(CloneFlowRequestSchema.shape));
+ const unknownKeys = Object.keys(cloneBody).filter((k) => !declaredKeys.has(k));
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 key${unknownKeys.length > 1 ? 's' : ''} ${unknownKeys.map((k) => `\`${k}\``).join(', ')} — the clone body is { name: string, label: string, package?: 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 }' })),
+ unknownKeys.map((k) => ({ field: k, code: 'unknown_field', message: 'not a clone field — the clone body is { name, label, package? }' })),
);
}
// The NEW MACHINE NAME IS MANDATORY (ADR-0126 §7.1, commit e170b0ae5's
@@ -3575,6 +3711,19 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
[{ field: 'label', code: targetLabel === undefined ? 'required' : 'invalid_type', message: 'expected a non-empty string' }],
);
}
+ // [#28149, ADR-0146 D4] `package` is optional — the package the
+ // copy lands in — and, when sent, a non-empty string: a blank or
+ // a non-string is refused here rather than read as "omitted",
+ // so a caller who meant to name a target never has the copy
+ // land somewhere it did not name.
+ const requestedPackage = (cloneBody as { package?: unknown }).package;
+ if (requestedPackage !== undefined && (typeof requestedPackage !== 'string' || requestedPackage.trim() === '')) {
+ throw validationFailure(
+ 'The clone\'s `package` names the package the copy lands in, and must be a non-empty package id '
+ + 'when sent. Omit it to land the copy in the source\'s paired customization package.',
+ [{ field: 'package', code: '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
@@ -3611,6 +3760,13 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
};
}
+ // [#28149, ADR-0146 D3/D4] WHERE the copy lands — never without
+ // a package (ADR-0070 D5/D6). Resolved after every refusal above
+ // and before anything is registered, so a refused target leaves
+ // no copy behind (see `resolveCloneTarget`).
+ const target = await resolveCloneTarget(deps, context, name, requestedPackage);
+ if ('refused' in target) return target.refused;
+
const clone = cloneFlowDefinition(source, { name: targetName, label: targetLabel });
// [#20761, ADR-0126 §7.1] The copy is TENANT-AUTHORED, stated by
// the server: `cloneFlowDefinition` drops the base's whole
@@ -3637,7 +3793,7 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
// deployment can no longer register. The target name is new (the
// collision probe above), so a save that fails withdraws it.
const written = await registerAndSaveFlow(
- deps, context, automationService as FlowRegistrationService, targetName, clone, 'clone',
+ deps, context, automationService as FlowRegistrationService, targetName, clone, 'clone', target.packageId,
);
if ('refused' in written) return written.refused;
// ⛔ NO ANCESTRY on the way out either (ADR-0126 amendment
@@ -3650,7 +3806,14 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
// [#20552] The ANSWER is a served definition like any other. A
// source holding a credential never reaches this line (#20790
// C1, above), so the clone carries none.
- return { handled: true, response: deps.success({ flow: servedFlowDefinition(clone), notice: FLOW_CLONE_NOTICE }) };
+ // [#28149] …and NAMES the package it landed in, so a console
+ // opens the copy where it lives (Setup's "Open the clone in
+ // Studio"): the package is where the copy is, not where it
+ // came from, so it records no lineage.
+ return {
+ handled: true,
+ response: deps.success({ flow: servedFlowDefinition(clone), packageId: target.packageId, notice: FLOW_CLONE_NOTICE }),
+ };
}
}
diff --git a/packages/runtime/src/flow-clone.ts b/packages/runtime/src/flow-clone.ts
index 74a9a3e21dc..07cf6c77cf7 100644
--- a/packages/runtime/src/flow-clone.ts
+++ b/packages/runtime/src/flow-clone.ts
@@ -8,6 +8,15 @@
* admin who cannot edit a packaged flow in place gets an ordinary,
* org-authored sibling to edit instead.
*
+ * [#28149, ADR-0146 D4] The clone survives only as "new similar": the copy
+ * lands in a writable PACKAGE — by default the source's paired customization
+ * package `.custom`, ensured through the protocol's ensure door — and is
+ * written switched off ({@link FLOW_CLONE_STATUS}). Choosing the package is
+ * the door's (`resolveCloneTarget`, `domains/automation.ts`); this module
+ * builds the copy, and the copy itself still carries no package envelope: the
+ * save into the target binds the row (`sys_metadata.package_id`), exactly as
+ * any save naming a package does.
+ *
* Three properties of that ADR are load-bearing here, and each one is a rule
* this module exists to make mechanical rather than remembered.
*
@@ -70,8 +79,8 @@
* (`tenantAuthoredWriteRefusal` in `@objectstack/metadata-protocol`) refuses a
* definition whose stamps claim a package for a name no package ships — so a
* copy that carried the base's envelope would be refused, not saved. The clone
- * door asks that rule of the copy this module builds and then saves it as an
- * ordinary tenant row. Pinned in `domains/automation-flow-clone.test.ts` (the
+ * door asks that rule of the copy this module builds and then saves it — since
+ * #28149 into its target package, never package-less. Pinned in `domains/automation-flow-clone.test.ts` (the
* envelope drop) and `domains/automation-tenant-authored-write.test.ts` (the
* rule and the save).
*
@@ -88,28 +97,29 @@
import { getMetadataTypeRedactor, 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.
+ * The deployment status a clone is created with: switched OFF.
+ *
+ * [#28149, ADR-0146 D4] "The copy is written **inactive**, so a 'new similar'
+ * never double-runs beside its original (Context §4)". The engine disables a
+ * flow on `status` `'obsolete'` or `'invalid'` only (`engine.ts`
+ * `registerFlow`); `'draft'`, the value a clone carried before, is a
+ * lifecycle label the engine arms exactly like `'active'`, so a clone of a
+ * record-change flow fired on the same writes as its base — measured on
+ * showcase at `d6c0a75f1a` (`showcase_urgent_task_alert` and its `_copy`
+ * both ran on one record change). `'obsolete'` is the one inactive value an
+ * author can flip back: it is what Studio's Enabled switch writes for Off on a
+ * writable package's flow, and what Studio's own new-flow skeleton starts
+ * with ("Off by default · review before enabling", `StudioDesignSurface`), so
+ * the copy opens in its package reading Disabled, and switching it on is the
+ * author's own flip and publish. `'invalid'` would claim a verdict nobody
+ * made.
+ *
+ * This supersedes ADR-0126 §7.2's reading that clone and disable stay
+ * independent primitives: ADR-0146 D4 re-homes the clone as "new similar"
+ * and rules the copy inactive. The ORIGINAL is still never touched — the copy
+ * is what starts switched off.
*/
-export const FLOW_CLONE_STATUS = 'draft' as const;
+export const FLOW_CLONE_STATUS = 'obsolete' as const;
/**
* The three fields a clone mutates — ADR-0126 §7.1, "mutates only
@@ -158,36 +168,27 @@ export const FLOW_CLONE_DROPPED_KEYS: readonly string[] = Object.freeze([
*
* Two facts, both required to be stated rather than discovered:
*
- * 1. 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 or schedule flow and walks away has two flows running on
- * one trigger, and the only thing standing between them and that surprise
- * is this line.
+ * 1. [#28149, ADR-0146 D4] The copy is switched OFF ({@link FLOW_CLONE_STATUS})
+ * and stays off until someone switches it on — "the response says so",
+ * in the server's words, so no console has to restate it. Before this, the
+ * same line warned that the copy ran alongside the original; the copy no
+ * longer does.
* 2. References are not re-pointed (ADR-0126 §9 — "the surface tells the
* admin so"). A cloned flow's subflow nodes, action calls and object
* references point exactly where the original's did.
*
* [#27886] The notice is ONE line in business words, because the console
- * shows it verbatim beside a "Switch the original off" button: a paragraph
- * that named `status`, lifecycle labels and both switches repeated what the
- * screen already offers. Engine words (`draft`, `status`, off-switch) and
- * the switch locations live here, not in the notice:
- *
- * - [#20726, ADR-0126 §7.2] The clone's own off-switch is its `status`
- * (`obsolete`). In the console that is Studio's Enabled switch set to
- * Disabled, then a publish. 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. That holds whatever the clone was copied from: the
- * clone door takes any registered flow as its source, packaged or not.
- * - A packaged original is switched off on the Packaged automation page,
- * with its `Switch "…" off` action; that switch does not reach the clone.
+ * shows it verbatim: engine words (`obsolete`, `status`) and the switch
+ * locations live here, not in the notice. The copy's switch is the Enabled
+ * switch on the flow in its package in Studio, then a publish: the copy is a
+ * writable package's flow (ADR-0146 D4's "definition switch"), never the
+ * activation ledger's, which switches read-only packages' flows.
*
* The notice names no endpoint or request body. The REST recipe is developer
- * copy and lives in the client SDK's `automation.toggle` TSDoc.
+ * copy and lives in the client SDK's `automation.clone` TSDoc.
*/
export const FLOW_CLONE_NOTICE =
- 'The copy runs alongside the original until you switch one of them off, '
+ 'The copy is switched off and does not run until you switch it on, '
+ 'and it still uses the same subflows, actions and objects as the original.';
/** ADR-0112 envelope for the same-name refusal: a status AND a code. */
diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts
index c906be8eaa9..74b1d3cf29e 100644
--- a/packages/runtime/src/route-ledger.ts
+++ b/packages/runtime/src/route-ledger.ts
@@ -594,10 +594,12 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
{ route: 'POST /automation/:name/trigger', domain: '/automation', disposition: 'sdk', client: 'automation.execute' },
{ route: 'POST /automation/:name/toggle', domain: '/automation', disposition: 'sdk', client: 'automation.toggle',
note: "enablement, and since the 2026-08-23 ruling that enablement is an authoring write, `manage_metadata` gates it — the same `isFlowAuthoringWrite` door as `POST /automation` above, NOT a fourth copy of the policy. The definition-write gate deliberately left this one out as engine state and filed the question; the measurement is what settled it. No organization wall scopes the enabled bit: `toggleFlow` writes the ADR-0126 §7.2 activation ledger first — one deployment-wide `sys_metadata_activation` row per flow, keyed by `(metadata_type, name)`, carrying the flow's package id and no organization column — and only then updates the engine's in-process projection, which `getFlowRuntimeStates()` reads with no caller and no organization; the automation service is ONE instance per environment — so an unentitled tenant org owner switched a shipped flow off and an unrelated tenant in a different organization, plus the platform admin, read it off, in both directions. Disabling a shipped flow is equivalent to deleting it for as long as it stays off, and DELETE was already gated. ⚠️ BREAKING: 200 → 403 for callers without the capability. Fail-closed on an absent executionContext, an absent `systemPermissions` or an empty one, refusing 403 `PERMISSION_DENIED`, with only `isSystem` bypassing; the domain-wide anonymous floor still answers 401 first. The predicate excludes `POST /automation/trigger/:name` so a flow literally NAMED `toggle` keeps its execution door. Pinned in `domains/automation-write-capability-gate.test.ts` and `qa/dogfood/test/automation-toggle-tenant-scope.dogfood.test.ts`" },
- // [#20676] The ADR-0126 §7.1 clone door. Its domain arm landed with #12156 and
+ // [#20676, #28149] The ADR-0126 §7.1 clone door. Its domain arm landed with #12156 and
// nothing mounted it until #20676, so the row and the mount arrive together.
- { route: 'POST /automation/:name/clone', domain: '/automation', disposition: 'server-only',
- note: "ADR-0126 §7.1 — copy a flow's whole definition under a NEW machine name: the sanctioned way to customize a packaged flow whose base is locked. Body `{ name, label }`, both mandatory, closed. An unknown source answers 404, a taken target name (the source's own included) 409 `RESOURCE_CONFLICT`, and a definition the engine refuses to register 400; success answers `{ flow, notice }`, the notice stating that references are not re-pointed and that the clone is armed (`status: 'draft'` is not an off-switch). ⛔ No ancestry on the definition or the response (ADR-0126 §9). ⚑ `manage_metadata` — the same `isFlowAuthoringWrite` door as `POST /automation` above, because a clone registers flow metadata at environment scope; fail-closed, only `isSystem` bypassing, and the domain-wide anonymous floor answers an unidentified caller 401 first. ⛔ NOT the ADR-0126 §5 activation gate: a clone takes nothing away from any tenant. The domain arm existed long before its mount did — every clone answered the transport's 404 before `dispatch()` ran while the arm's direct-call unit test stayed green — so the door is pinned over HTTP, not only through the handler. NOT JS-SDK surface on this leg, and that is stated rather than left as an open gap: the operational driver ADR-0126 §7.4 charters is the Setup page for packaged metadata, a console surface that calls the platform API directly — the posture the `POST /actions/_activation/:object/:action` row below carries. Adding a client method reclassifies this row to `sdk`. Pinned over HTTP in `qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts`, the environment-scoped mount in `dispatcher-plugin.automation-clone-mount.integration.test.ts`, and the copy itself in `domains/automation-flow-clone.test.ts`" },
+ { route: 'POST /automation/:name/clone', domain: '/automation', disposition: 'sdk', client: 'automation.clone',
+ requestSchema: 'CloneFlowRequestSchema',
+ responseSchema: 'CloneFlowResponseSchema',
+ note: "[ADR-0146 D4] \"New similar\" — copy a flow's whole definition under a NEW machine name, INTO A PACKAGE, switched off. Body `CloneFlowRequestSchema`: `{ name, label }` mandatory, `package` optional, closed (an undeclared key is refused 400 on that key; the door reads the member set off the schema). `package` given: an installed, writable package whose dependency closure holds the source's package (`newSimilarTargetRefusal`, `domains/new-similar-target.ts`: unknown or read-only 422 `WRITABLE_PACKAGE_REQUIRED`, outside the closure 409 `RESOURCE_CONFLICT`). Omitted: the source's paired customization package `.custom`, `` being the package the loader's set says ships the source, ensured through the protocol's `ensureCustomizationPackage` — the body of `POST /packages/:id/ensure-customization`, called in process, never re-implemented; a source no package ships has no pair and is refused 400 on `package`. ⛔ Never a package-less copy (ADR-0070 D5/D6). The copy is written `status: 'obsolete'`, so it never runs beside its original; its save is a first write (a row the target already holds under the name is refused 409, never overwritten). An unknown source answers 404, a taken name 409 `RESOURCE_CONFLICT`, a source holding a credential 409, a definition the engine refuses 400. The schema names the WHOLE BODY, envelope included (as the uninstall row's does): `data` is `{ flow, packageId, notice }`, `packageId` the package the copy landed in, the notice saying the copy is switched off and that references are not re-pointed. ⛔ No ancestry on the definition or the response (ADR-0126 §9). ⚑ `manage_metadata` — the same `isFlowAuthoringWrite` door as `POST /automation` above, then the walled refusal (the gates the ensure door asks, so the in-process ensure creates nothing a caller could not create there); fail-closed, only `isSystem` bypassing, and the domain-wide anonymous floor answers an unidentified caller 401 first. ⛔ NOT the ADR-0126 §5 activation gate: a clone takes nothing away from any tenant. Conformance: `domains/automation-flow-clone-custom-package.test.ts` drives THIS handler over the real protocol, registry and store, parses each answer through `CloneFlowResponseSchema` and pins this row; the copy itself in `domains/automation-flow-clone.test.ts`; over HTTP in `qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts`, the environment-scoped mount in `dispatcher-plugin.automation-clone-mount.integration.test.ts`" },
{ route: 'POST /automation/:name/runs/:runId/resume', domain: '/automation', disposition: 'sdk', client: 'automation.resume',
note: "generic, so the SUSPENDED NODE gates it: a pause whose descriptor declares resumeAuthority:'service' — today `approval` / `approval_revise` — answers 403 here and continues only through its owning service (ApprovalService.decide), which authorizes and records the decision first. A node type that declares NO resumeAuthority answers 403 too, fail-closed: this door is an opt-in a descriptor states with 'any'. Screen/wait pauses are unaffected because they declare it; this route is the screen-flow runner's door. The node gate asks WHAT the run is parked on, never WHO is resuming, so the route also gates the CALLER on the screen read's own question: the run's own trigger identity, or the `sys_automation_run` read grant as the operator override — one predicate, `isRunStarterOrRunStateReader` in `domains/automation.ts`. Refused 403 `PERMISSION_DENIED` before the engine is reached, so nothing is consumed. Pinned in `domains/automation-resume-caller-gate.test.ts`" },
{ route: 'GET /automation/:name/runs/:runId/screen', domain: '/automation', disposition: 'sdk', client: 'automation.getScreen' },
diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json
index c402618953f..95b10f2bc9f 100644
--- a/packages/spec/api-surface/api.json
+++ b/packages/spec/api-surface/api.json
@@ -182,6 +182,12 @@
"CheckPermissionResponseSchema (const)",
"CloneDataResponse (type)",
"CloneDataResponseSchema (const)",
+ "CloneFlowRequest (type)",
+ "CloneFlowRequestParsed (type)",
+ "CloneFlowRequestSchema (const)",
+ "CloneFlowResponse (type)",
+ "CloneFlowResponseParsed (type)",
+ "CloneFlowResponseSchema (const)",
"CodeGenerationTemplate (type)",
"CodeGenerationTemplateSchema (const)",
"CompleteChunkedUploadRequest (type)",
diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json
index 15d3d8d0d7a..5fc2984b66e 100644
--- a/packages/spec/authorable-surface/api.json
+++ b/packages/spec/authorable-surface/api.json
@@ -323,6 +323,13 @@
"api/CloneDataResponse:record",
"api/CloneDataResponse:sourceId",
"api/CloneDataResponse:warnings",
+ "api/CloneFlowRequest:label",
+ "api/CloneFlowRequest:name",
+ "api/CloneFlowRequest:package",
+ "api/CloneFlowResponse:data",
+ "api/CloneFlowResponse:error",
+ "api/CloneFlowResponse:meta",
+ "api/CloneFlowResponse:success",
"api/CodeGenerationTemplate:language",
"api/CodeGenerationTemplate:name",
"api/CodeGenerationTemplate:template",
diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json
index 6f197c59b18..b60c10ce26d 100644
--- a/packages/spec/declaration-map/api.json
+++ b/packages/spec/declaration-map/api.json
@@ -135,6 +135,10 @@
"CheckPermissionResponseSchema": "api/CheckPermissionResponse",
"CloneDataResponse": "api/CloneDataResponse",
"CloneDataResponseSchema": "api/CloneDataResponse",
+ "CloneFlowRequest": "api/CloneFlowRequest",
+ "CloneFlowRequestSchema": "api/CloneFlowRequest",
+ "CloneFlowResponse": "api/CloneFlowResponse",
+ "CloneFlowResponseSchema": "api/CloneFlowResponse",
"CodeGenerationTemplate": "api/CodeGenerationTemplate",
"CodeGenerationTemplateSchema": "api/CodeGenerationTemplate",
"CompleteChunkedUploadRequest": "api/CompleteChunkedUploadRequest",
diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json
index 44aba1ff614..64a08ba5420 100644
--- a/packages/spec/dropped-refinements.baseline.json
+++ b/packages/spec/dropped-refinements.baseline.json
@@ -2,8 +2,8 @@
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
"measured": {
"zod": "4.4.3",
- "publishedSchemasWithDroppedRefinements": 246,
- "droppedRefinementSites": 948,
+ "publishedSchemasWithDroppedRefinements": 247,
+ "droppedRefinementSites": 951,
"refinementSitesThatDidProject": 369,
"refinementSitesWithNoJsonFormToCompare": 0
},
@@ -142,6 +142,13 @@
"manifest.views.element.list.timeline.groupByField"
]
},
+ "api/CloneFlowResponse": {
+ "sites": [
+ "data.flow",
+ "data.flow.errorHandling",
+ "data.flow.nodes.element.in.waitEventConfig"
+ ]
+ },
"api/CreateFlowRequest": {
"sites": [
"",
diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json
index e29d11666b9..73133f764d9 100644
--- a/packages/spec/export-origins/api.json
+++ b/packages/spec/export-origins/api.json
@@ -172,6 +172,12 @@
"CheckPermissionResponseSchema": "src/api/protocol.zod.ts#CheckPermissionResponseSchema (const)",
"CloneDataResponse": "src/api/protocol.zod.ts#CloneDataResponse (type)",
"CloneDataResponseSchema": "src/api/protocol.zod.ts#CloneDataResponseSchema (const)",
+ "CloneFlowRequest": "src/api/automation-api.zod.ts#CloneFlowRequest (type)",
+ "CloneFlowRequestParsed": "src/api/automation-api.zod.ts#CloneFlowRequestParsed (type)",
+ "CloneFlowRequestSchema": "src/api/automation-api.zod.ts#CloneFlowRequestSchema (const)",
+ "CloneFlowResponse": "src/api/automation-api.zod.ts#CloneFlowResponse (type)",
+ "CloneFlowResponseParsed": "src/api/automation-api.zod.ts#CloneFlowResponseParsed (type)",
+ "CloneFlowResponseSchema": "src/api/automation-api.zod.ts#CloneFlowResponseSchema (const)",
"CodeGenerationTemplate": "src/api/documentation.zod.ts#CodeGenerationTemplate (type)",
"CodeGenerationTemplateSchema": "src/api/documentation.zod.ts#CodeGenerationTemplateSchema (const)",
"CompleteChunkedUploadRequest": "src/api/storage.zod.ts#CompleteChunkedUploadRequest (type)",
diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json
index 57ad289451b..9a261b3e900 100644
--- a/packages/spec/json-schema.manifest/api.json
+++ b/packages/spec/json-schema.manifest/api.json
@@ -72,6 +72,8 @@
"api/CheckPermissionRequest",
"api/CheckPermissionResponse",
"api/CloneDataResponse",
+ "api/CloneFlowRequest",
+ "api/CloneFlowResponse",
"api/CodeGenerationTemplate",
"api/CompleteChunkedUploadRequest",
"api/CompleteChunkedUploadResponse",
diff --git a/packages/spec/src/api/automation-api.zod.test.ts b/packages/spec/src/api/automation-api.zod.test.ts
index 49f454e24e5..b1cb192ea29 100644
--- a/packages/spec/src/api/automation-api.zod.test.ts
+++ b/packages/spec/src/api/automation-api.zod.test.ts
@@ -893,10 +893,13 @@ describe('AutomationApiErrorCode', () => {
// ==========================================
describe('AutomationApiContracts', () => {
- it('should define all 8 contract endpoints', () => {
+ it('should define all 9 contract endpoints', () => {
// 9 -> 8: `listFlows` (`GET /api/v1/automation`) retired with its route
// (#19543) — flows are listed through `GET /api/v1/meta/flow`.
- expect(Object.keys(AutomationApiContracts)).toHaveLength(8);
+ // 8 -> 9: `cloneFlow` (`POST /api/v1/automation/:name/clone`) gained its
+ // request/response schemas (#28149, ADR-0146 D4 "new similar").
+ expect(Object.keys(AutomationApiContracts)).toHaveLength(9);
+ expect(AutomationApiContracts).toHaveProperty('cloneFlow');
});
it('declares no flow-list entry and no GET at the bare /api/v1/automation path', () => {
diff --git a/packages/spec/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts
index 6d1f4804fcc..d1923dd18bb 100644
--- a/packages/spec/src/api/automation-api.zod.ts
+++ b/packages/spec/src/api/automation-api.zod.ts
@@ -42,6 +42,7 @@ import { ExecutionLogSchema, ExecutionStatus, FlowRunSummarySchema } from '../au
* DELETE /api/v1/automation/:name — Delete flow
* POST /api/v1/automation/:name/trigger — Trigger flow execution
* POST /api/v1/automation/:name/toggle — Enable/disable a packaged flow
+ * POST /api/v1/automation/:name/clone — Copy a flow, switched off, into a package
* GET /api/v1/automation/:name/runs — List execution runs
* GET /api/v1/automation/:name/runs/:runId — Get single execution run
* ```
@@ -655,6 +656,76 @@ export type ToggleFlowResponse = z.input;
/** Post-parse shape of {@link ToggleFlowResponse} — defaults applied, transforms run (ADR-0122). */
export type ToggleFlowResponseParsed = z.infer;
+// ==========================================
+// 8a. Clone Flow (POST /api/v1/automation/:name/clone)
+// ==========================================
+
+/**
+ * [#28149, ADR-0146 D4] Request body of the flow clone door — "new similar":
+ * a whole-definition copy of the flow at `:name`, under a NEW machine name,
+ * written into a writable package and switched off.
+ *
+ * Closed: the door refuses a key not declared here (400 `VALIDATION_FAILED`,
+ * located on the key), so a caller who meant to choose the copy's `status`
+ * or its envelope learns that nothing was read. The path's `:name` is the
+ * SOURCE; this body's `name` is the copy's.
+ *
+ * `package` names where the copy lands. Omitted, the copy lands in the
+ * source's paired customization package `.custom` (ADR-0146 D3),
+ * created on the customer's first customization through the protocol's
+ * ensure door, the one creation path of the pair. A source that no read-only
+ * package ships has no pair, so its copy needs `package`. ⛔ The copy is
+ * never written without a package (ADR-0070 D5/D6).
+ *
+ * @example POST /api/v1/automation/crm_lead_followup/clone
+ * { name: 'acme_lead_followup', label: 'Lead Follow-up (ACME)' }
+ */
+export const CloneFlowRequestSchema = lazySchema(() => z.object({
+ name: z.string().min(1).describe(
+ 'The copy\'s machine name — mandatory, and a name no flow uses (ADR-0126 §7.1): the automation '
+ + 'engine keys flows by bare name, so a taken name is refused 409 `RESOURCE_CONFLICT`.',
+ ),
+ label: z.string().min(1).describe(
+ 'The copy\'s display name — mandatory, so the copy and its source are told apart in every list.',
+ ),
+ package: z.string().min(1).optional().describe(
+ 'Id of the package the copy lands in. Given, it must be an installed, writable package whose '
+ + 'dependency closure holds the source\'s package: an unknown or read-only one is refused 422 '
+ + '`WRITABLE_PACKAGE_REQUIRED`, one that declares no dependency path to the source\'s package 409 '
+ + '`RESOURCE_CONFLICT`, and nothing is written. Omitted, the copy lands in the source\'s paired '
+ + 'customization package `.custom`, created on first use; a source no read-only package ships '
+ + 'has no pair, and the request is refused 400 `VALIDATION_FAILED` located on this member.',
+ ),
+}).strict());
+export type CloneFlowRequest = z.input;
+export type CloneFlowRequestParsed = z.infer;
+
+/**
+ * Response of the flow clone door.
+ *
+ * `data.flow` is the copy as stored: the source's whole definition, renamed,
+ * relabelled and switched off (`status: 'obsolete'`), with no package
+ * envelope and no reference to the source (ADR-0126 §9: no clone lineage).
+ * `data.packageId` is the package the copy landed in, so a console opens the
+ * copy where it lives.
+ */
+export const CloneFlowResponseSchema = lazySchema(() => BaseResponseSchema.extend({
+ data: z.object({
+ flow: FlowSchema.describe('The copy as stored: switched off, in `packageId`, with no lineage.'),
+ packageId: z.string().describe(
+ 'The package the copy landed in: the one the request named, else the source\'s paired '
+ + 'customization package `.custom`.',
+ ),
+ notice: z.string().describe(
+ 'One line for the administrator, in the server\'s words: the copy is switched off until '
+ + 'someone switches it on, and its references are not re-pointed.',
+ ),
+ }),
+}));
+export type CloneFlowResponse = z.input;
+/** Post-parse shape of {@link CloneFlowResponse} — defaults applied, transforms run (ADR-0122). */
+export type CloneFlowResponseParsed = z.infer;
+
// ==========================================
// 9. List Runs (GET /api/v1/automation/:name/runs)
// ==========================================
@@ -843,6 +914,12 @@ export const AutomationApiContracts = {
input: ToggleFlowRequestSchema,
output: ToggleFlowResponseSchema,
},
+ cloneFlow: {
+ method: 'POST' as const,
+ path: '/api/v1/automation/:name/clone',
+ input: CloneFlowRequestSchema,
+ output: CloneFlowResponseSchema,
+ },
listRuns: {
method: 'GET' as const,
path: '/api/v1/automation/:name/runs',
diff --git a/ui/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx b/ui/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx
index 3e9418abc6b..0bea32925bc 100644
--- a/ui/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx
+++ b/ui/packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx
@@ -41,9 +41,10 @@
* objectstack#27378 such a row's switch draws a muted on-track, and a
* `deployment_policy` row says what that policy is in plain words; the
* platform's sentence stays behind Details.
- * 8. **the clone result's next steps** (objectui#13270) — a clone runs
- * beside its source, so the result offers to switch the source off
- * through the SAME toggle as its row, and links the clone's Studio scope.
+ * 8. **the clone result's next steps** (objectui#13270) — the result offers
+ * to switch the source off
+ * through the SAME toggle as its row, and links the copy in the Studio
+ * package the server named (#28149).
* An off source is offered nothing to switch; a refusal is the server's
* sentence and the offer stays.
* 9. **the clone prefill** (objectui#13344) — the dialog opens on
@@ -99,9 +100,12 @@ const CLONE_CONFLICT_REFUSAL =
/** `FLOW_CLONE_NOTICE` — returned on every successful clone. */
const CLONE_NOTICE =
- 'The copy runs alongside the original until you switch one of them off, ' +
+ 'The copy is switched off and does not run until you switch it on, ' +
'and it still uses the same subflows, actions and objects as the original.';
+/** [#28149] The package the server says the copy landed in: the source's paired customization package. */
+const CLONE_PACKAGE = 'com.acme.pkg.custom';
+
/**
* `SCHEDULED_WORK_DISABLED_REASON` (`@objectstack/types` 17.5.0), the sentence
* the automation engine puts in `FlowRuntimeState.reason` for a time-triggered
@@ -219,7 +223,7 @@ beforeEach(() => {
const payload = body as { name: string; label: string };
return response(200, {
success: true,
- data: { flow: { name: payload.name, label: payload.label }, notice: CLONE_NOTICE },
+ data: { flow: { name: payload.name, label: payload.label }, packageId: CLONE_PACKAGE, notice: CLONE_NOTICE },
});
}
throw new Error(`unexpected fetch: ${method} ${url}`);
@@ -838,15 +842,30 @@ describe('the clone result offers the next steps', () => {
expect(screen.getByText('Created flow "crm_notify_owner".')).toBeInTheDocument();
});
- it('links the clone in the package-less Studio scope (pin b)', async () => {
+ it('links the clone in the Automations pillar of the package the server named (pin b, #28149)', async () => {
await renderPage();
await cloneRow('pkg_notify', 'crm_notify_owner');
const open = screen.getByTestId('packaged-clone-open');
expect(open.textContent).toBe('Open the clone in Studio');
const href = open.getAttribute('href') ?? '';
- expect(href.startsWith('/studio/~org/automations')).toBe(true);
- expect(decodeURIComponent(href)).toContain('crm_notify_owner');
+ // The answer's `packageId`, read — never the package-less scope, where
+ // the copy no longer lives.
+ expect(href.startsWith(`/studio/${encodeURIComponent(CLONE_PACKAGE)}/automations`)).toBe(true);
+ expect(href.startsWith('/studio/~org')).toBe(false);
+ expect(decodeURIComponent(href)).toContain('flow:crm_notify_owner');
+ });
+
+ it('draws no Studio link when the answer names no package — never one that lands nowhere (pin b, control)', async () => {
+ fixture.clone = [{
+ status: 200,
+ body: { success: true, data: { flow: { name: 'crm_notify_owner', label: 'Notify owner (ours)' }, notice: CLONE_NOTICE } },
+ }];
+ await renderPage();
+ await cloneRow('pkg_notify', 'crm_notify_owner');
+
+ expect(screen.getByText('Created flow "crm_notify_owner".')).toBeInTheDocument();
+ expect(screen.queryByTestId('packaged-clone-open')).toBeNull();
});
it('shows a refused switch-off verbatim and keeps the offer (pin c)', async () => {
diff --git a/ui/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx b/ui/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx
index 938a35c6c84..a0895fde22b 100644
--- a/ui/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx
+++ b/ui/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx
@@ -36,11 +36,11 @@
*
* ## The clone result offers the next steps (objectui#13270)
*
- * A clone is created `draft`, which is NOT an off-switch: a cloned
- * record-change or schedule flow runs beside the flow it was copied from, and
- * the server's notice says so. So the result keeps that notice verbatim and
- * offers the two doors an admin otherwise has to find alone, as the declared
- * sharing rule's clone does (`SharingRuleCloneDialog`):
+ * [#28149, ADR-0146 D4] A clone is "new similar": the server writes the copy
+ * switched off into a package — the source's paired customization package
+ * `.custom` — and its notice says so. The result keeps that notice
+ * verbatim and offers the two doors an admin otherwise has to find alone, as
+ * the declared sharing rule's clone does (`SharingRuleCloneDialog`):
*
* - **Switch "SOURCE" off** — the SAME packaged-flow toggle the row's switch
* calls, for the row the admin cloned from. It is the page's own knowledge
@@ -48,8 +48,10 @@
* an offer, not a step: clone and disable stay independent primitives
* (ADR-0126). Offered only while that row reads on; a refusal is the
* server's sentence, shown beside the button, which stays offered.
- * - **Open the clone in Studio** — the package-less scope where a clone is
- * listed and edited (`studioOrgScopePath`, objectui#11553).
+ * - **Open the clone in Studio** — the Automations pillar of the package
+ * the copy landed in, read off the answer's `packageId` (never derived
+ * here), opened on the copy (`artifactStudioPath`). An answer that names
+ * no package draws no link rather than one that lands nowhere.
*
* For the same reason this page shows no `bound` / `status` column even though
* `_status` carries both: §7.4 scopes it to what the activation ledger knows.
@@ -160,7 +162,7 @@ import {
} from './packagedFlows.js';
import { requestFlowToggle, type FlowToggleOutcome } from './packagedFlowLedger.js';
import { PackagedActionsSection } from './PackagedActionsSection.js';
-import { studioOrgScopePath } from '../studio-design/studioScope.js';
+import { artifactStudioPath } from '../../console/ai/artifactStudioPath.js';
import { PageHeader } from '../../layout/PageHeader.js';
/* -------------------------------------------------------------------------- */
@@ -235,6 +237,8 @@ interface CloneResult {
source: string;
/** That flow's display label, as its row shows it. */
sourceLabel: string;
+ /** [#28149] The package the copy landed in, as the server named it. */
+ packageId: string | null;
/** The switch-off offer's request is in flight. */
switchBusy: boolean;
/** The toggle's refusal of the switch-off, verbatim. */
@@ -470,17 +474,19 @@ export function PackagedAutomationPage() {
const data = envelopeData(json);
const created = (data.flow as { name?: unknown } | undefined)?.name;
const notice = data.notice;
+ const landedIn = data.packageId;
setClone(null);
setCloneResult({
name: typeof created === 'string' && created ? created : draft.name.trim(),
notice: typeof notice === 'string' ? notice : '',
source: draft.source,
sourceLabel: rows?.find((r) => r.name === draft.source)?.label ?? draft.source,
+ packageId: typeof landedIn === 'string' && landedIn !== '' ? landedIn : null,
switchBusy: false,
switchRefusal: null,
});
- // The clone is a tenant artifact, so it does NOT join this packaged
- // list. Refetch anyway: the source flow's own state is re-read, and a
+ // The copy lands in a writable package, so it does NOT join this
+ // packaged list. Refetch anyway: the source flow's own state is re-read, and a
// stale list is the one thing an operational page must not show.
setNonce((n) => n + 1);
} catch (e) {
@@ -499,6 +505,10 @@ export function PackagedAutomationPage() {
const cloneNameValid = !!clone && clone.name.trim() !== '' && clone.label.trim() !== '';
/** The cloned-from row as the list shows it NOW, so the offer follows its switch. */
const cloneSourceRow = cloneResult ? rows?.find((r) => r.name === cloneResult.source) : undefined;
+ /** [#28149] The copy in its package's Automations pillar, where the server said it landed. */
+ const cloneOpenPath = cloneResult?.packageId
+ ? artifactStudioPath(cloneResult.packageId, { type: 'flow', name: cloneResult.name })
+ : null;
return (
@@ -570,14 +580,13 @@ export function PackagedAutomationPage() {
})}
) : null}
-
+ {cloneOpenPath && (
+
+ )}
{cloneResult.switchRefusal && (
// VERBATIM, beside the offer that caused it; the offer stays.