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.