From 4a871dc4accbd2bf8e53debdd399f3405ec48ed2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 03:59:29 +0000 Subject: [PATCH 1/6] fix(runtime): mount POST /automation/:name/clone on the dispatcher bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR-0126 §7.1 clone arm in domains/automation.ts was never mounted by registerAutomationRoutes, so every flow clone answered the transport's 404 before dispatch() ran. Mounted beside /:name/toggle at both the plain and the environment-scoped base. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- packages/runtime/src/dispatcher-plugin.ts | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index ebf50bc224a..b6dc62c92f0 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1575,6 +1575,30 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu } }); + // [#20676] ADR-0126 §7.1 — the clone door, the sanctioned way + // to customize a packaged flow whose base is locked. The + // domain arm (`domains/automation.ts`, `manage_metadata`-gated + // through `isFlowAuthoringWrite`) has existed since #12156, but + // nothing mounted it here, so every clone — from the API and + // from Setup's Clone dialog — answered the transport's 404 + // before `dispatch()` ran, while the arm's unit test stayed + // green because it calls the handler directly. Pinned over + // HTTP in `qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts`. + // + // Same shape as `/:name/toggle` and registered after + // `trigger/:name`, so `POST /automation/trigger/clone` keeps + // reaching the legacy execution door for a flow literally + // named `clone` — and either mount rebuilds the identical + // dispatch path, which the domain answers `trigger` first. + server!.post(`${base}/automation/:name/clone`, async (req: any, res: any) => { + try { + const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/clone`, req.body, req.query, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + server!.get(`${base}/automation/:name/runs`, async (req: any, res: any) => { try { const result = await dispatcher.dispatch('GET', `/automation/${req.params.name}/runs`, undefined, req.query, { request: req }); From 8ab0c816a3e4e9661244cfcb4d0588d2e0631245 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:01:30 +0000 Subject: [PATCH 2/6] test(runtime,dogfood): ledger the flow clone door and pin it over HTTP at both bases Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- ...automation-flow-clone-door.dogfood.test.ts | 141 ++++++++++++++++++ ...automation-clone-mount.integration.test.ts | 112 ++++++++++++++ packages/runtime/src/route-ledger.ts | 6 +- 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 packages/qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts create mode 100644 packages/runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.ts 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 new file mode 100644 index 00000000000..d42abd4f180 --- /dev/null +++ b/packages/qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20676 — `POST /api/v1/automation/:name/clone` answers over HTTP. + * + * ## What was broken, and why the existing pin could not see it + * + * ADR-0126 §7.1's clone door is how an admin customizes a packaged flow whose + * base is locked. Its domain arm (`runtime/src/domains/automation.ts`) landed + * with #12156, but the dispatcher bridge (`registerAutomationRoutes` in + * `runtime/src/dispatcher-plugin.ts`) mounts every `/automation` route + * explicitly and never mounted this one. So every clone — from the API and + * from Setup's Clone dialog — answered the transport's + * `404 ENDPOINT_NOT_FOUND` before `dispatch()` ran, for every body and every + * caller. `domains/automation-flow-clone.test.ts` stayed green throughout + * because it drives `HttpDispatcher` directly, below the mount. + * + * This file drives the REAL composition instead: `bootStack` boots the CRM app + * with the automation service over the in-process Hono app, so a request here + * crosses exactly the mount a browser crosses. + * + * ## Why each case discriminates the mount + * + * An unmounted path answers 404 to everyone, so none of the verdicts below can + * be produced without the mount: the anonymous floor's `UNAUTHENTICATED` is + * minted inside the automation domain, and so are the 200 with its notice, the + * 409 and the 400. The environment-scoped twin + * (`/environments/:environmentId/automation/:name/clone`) is not mounted by + * this harness at all (it boots the dispatcher without project scoping); it is + * pinned over a real socket in + * `runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +// Read from the implementation rather than restated: the notice is a contract +// value, and a second spelling of it would agree only until one of them moves. +import { FLOW_CLONE_NOTICE } from '../../../runtime/src/flow-clone.js'; + +/** The CRM app's own shipped flow — a real definition, not one this test injects. */ +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)'; + +interface ErrorBody { success?: boolean; error?: { code?: string; httpStatus?: number } } + +describe('#20676 — POST /automation/:name/clone is mounted on the dispatcher bridge', () => { + let stack: VerifyStack; + let adminToken: string; + + beforeAll(async () => { + stack = await bootStack(crmStack as never, { automation: true }); + adminToken = await stack.signIn(); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('an anonymous caller is refused by the automation domain — 401 UNAUTHENTICATED, not the transport 404', async () => { + const res = await stack.api(`/automation/${SOURCE}/clone`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'anon_clone_20676', label: 'Anonymous clone' }), + }); + const text = await res.clone().text(); + expect(res.status, `anonymous clone: ${text}`).toBe(ANONYMOUS_DENY_STATUS); + expect(((await res.json()) as ErrorBody).error?.code).toBe(ANONYMOUS_DENY_CODE); + + // Nothing was registered under the anonymous caller's name. + const readBack = await stack.apiAs(adminToken, 'GET', '/automation/anon_clone_20676'); + expect(readBack.status).toBe(404); + }); + + it('a legal clone answers 200 with the flow 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}`); + expect(before.status, `pre-clone read of ${CLONE}`).toBe(404); + + const res = await stack.apiAs(adminToken, 'POST', `/automation/${SOURCE}/clone`, { + name: CLONE, + label: CLONE_LABEL, + }); + const text = await res.clone().text(); + 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 }; + }; + 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' }); + + const readBack = await stack.apiAs(adminToken, 'GET', `/automation/${CLONE}`); + const readText = await readBack.clone().text(); + expect(readBack.status, `read-back of ${CLONE}: ${readText}`).toBe(200); + const read = (await readBack.json()) as { data?: { name?: string; label?: string; type?: string } }; + expect(read.data).toMatchObject({ name: CLONE, label: CLONE_LABEL, type: 'screen' }); + + // The source is untouched by its clone. + const source = await stack.apiAs(adminToken, 'GET', `/automation/${SOURCE}`); + expect(source.status).toBe(200); + expect(((await source.json()) as { data?: { name?: string } }).data?.name).toBe(SOURCE); + }); + + it('an illegal machine name answers 400, and nothing is registered under it', async () => { + const illegal = 'Not A Machine Name'; + const res = await stack.apiAs(adminToken, 'POST', `/automation/${SOURCE}/clone`, { + name: illegal, + label: 'Illegal clone', + }); + const text = await res.clone().text(); + expect(res.status, `illegal-name clone: ${text}`).toBe(400); + expect(((await res.json()) as ErrorBody).error?.code).toBe('VALIDATION_FAILED'); + + const readBack = await stack.apiAs(adminToken, 'GET', `/automation/${encodeURIComponent(illegal)}`); + expect(readBack.status).toBe(404); + }); + + it('a clone with no `name` is refused 400 before anything is registered', async () => { + const res = await stack.apiAs(adminToken, 'POST', `/automation/${SOURCE}/clone`, { label: 'No name' }); + const text = await res.clone().text(); + expect(res.status, `name-less clone: ${text}`).toBe(400); + expect(((await res.json()) as ErrorBody).error?.code).toBe('VALIDATION_FAILED'); + }); + + it('a taken name answers 409 RESOURCE_CONFLICT — the same-name clone ADR-0126 §7.1 refuses', async () => { + const res = await stack.apiAs(adminToken, 'POST', `/automation/${SOURCE}/clone`, { + name: SOURCE, + label: 'Same-name clone', + }); + const text = await res.clone().text(); + expect(res.status, `same-name clone: ${text}`).toBe(409); + expect(((await res.json()) as ErrorBody).error?.code).toBe('RESOURCE_CONFLICT'); + }); +}); diff --git a/packages/runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.ts b/packages/runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.ts new file mode 100644 index 00000000000..33cc53381ae --- /dev/null +++ b/packages/runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20676 — the flow clone door is MOUNTED, at both bases `registerAutomationRoutes` + * serves. + * + * ## Why a socket, and why this file beside the dogfood pin + * + * `domains/automation.ts` has answered `POST /:name/clone` (ADR-0126 §7.1) + * since #12156, but the bridge mounts every `/automation` route explicitly and + * never mounted this one, so on a real host the transport's `notFound` + * answered before `dispatch()` ran. A test that calls the handler cannot see + * that; only a request that crosses the mount can. + * `qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts` drives the + * clone end to end on a composed app, but its harness boots the dispatcher + * WITHOUT project scoping, so the environment-scoped twin + * (`${prefix}/environments/:environmentId/automation/:name/clone`) is + * unobservable there. This file boots the composition that mounts both. + * + * ## The composition, and the discriminator + * + * `plugin-hono-server` + the dispatcher, `enableProjectScoping: true` under + * `projectResolution: 'auto'` — the branch that mounts the plain AND the + * scoped base. No `createHonoApp`, no service plugins, so a mount is the only + * way in. The discriminator is `dispatcher-plugin.scoped-packages-door.integration.test.ts`'s: + * the automation domain's first statement is the anonymous-deny floor, so a + * credential-less request that REACHES the dispatcher answers + * `ANONYMOUS_DENY_STATUS` / `ANONYMOUS_DENY_CODE` (imported, not spelled) — a + * verdict no transport-level sink emits — while a path no mount claims answers + * the transport's own 404. The negative control measures that second direction + * on a sibling path rather than assuming it. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_STATUS, LiteKernel } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +const PREFIX = '/api/v1'; +const ENV_ID = 'env_alpha'; +const FLOW = 'crm_convert_lead_wizard'; + +let kernel: LiteKernel | undefined; +let baseUrl = ''; + +beforeAll(async () => { + kernel = new LiteKernel(); + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); + kernel.use(createDispatcherPlugin({ + prefix: PREFIX, + scoping: { enableProjectScoping: true, projectResolution: 'auto' }, + enforceProjectMembership: false, + securityHeaders: false, + })); + await kernel.bootstrap(); + const httpServer = kernel.getService('http.server'); + baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; +}, 60_000); + +afterAll(async () => { + if (!kernel) return; + await Promise.race([ + kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); +}, 60_000); + +async function post(path: string): Promise<{ status: number; body: any }> { + const res = await fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'probe_clone_20676', label: 'Probe clone' }), + }); + let body: any; + try { body = await res.json(); } catch { body = undefined; } + return { status: res.status, body }; +} + +/** The anonymous floor answered — minted inside `dispatch()`, never by the transport. */ +function dispatcherAnswered(r: { status: number; body: any }): boolean { + return r.status === ANONYMOUS_DENY_STATUS && r.body?.error?.code === ANONYMOUS_DENY_CODE; +} + +/** The shape "no door answered" takes on this transport. */ +function transportRefused(r: { status: number; body: any }): boolean { + const code = r.body?.error?.code; + return r.status === 404 && (code === undefined || code === 'ROUTE_NOT_FOUND' || code === 'ENDPOINT_NOT_FOUND'); +} + +describe('#20676 — the discriminator, measured in both directions', () => { + it('POSITIVE CONTROL: the sibling `/:name/toggle` mount answers from the domain', async () => { + const r = await post(`${PREFIX}/automation/${FLOW}/toggle`); + expect(dispatcherAnswered(r), `toggle -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true); + }, 60_000); + + it('NEGATIVE CONTROL: an unmounted sibling segment answers the transport, not the domain', async () => { + const r = await post(`${PREFIX}/automation/${FLOW}/no-such-verb`); + expect(dispatcherAnswered(r)).toBe(false); + expect(transportRefused(r), `unmounted sibling -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true); + }, 60_000); +}); + +describe('#20676 — POST /automation/:name/clone crosses the mount at both bases', () => { + for (const base of [PREFIX, `${PREFIX}/environments/${ENV_ID}`]) { + it(`POST ${base}/automation/:name/clone answers through the dispatcher`, async () => { + const r = await post(`${base}/automation/${FLOW}/clone`); + expect(dispatcherAnswered(r), `clone at ${base} -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true); + }, 60_000); + } +}); diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 0229afa9be0..df47c018bda 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -277,7 +277,7 @@ export const NON_DISPATCH_MOUNT_PREFIXES = [ /** * The ledger. * - * CENSUS (generated): this list holds 81 rows. + * CENSUS (generated): this list holds 82 rows. * * ⛔ THAT NUMBER IS WRITTEN BY A TOOL — never by hand. * `pnpm check:route-ledger-census` counts the rows below and fails when the two @@ -439,6 +439,10 @@ 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 #10243 ruling (2026-08-23) `manage_metadata` gates it — the same `isFlowAuthoringWrite` door as `POST /automation` above, NOT a fourth copy of the policy. #10145 deliberately left this one out as engine state and filed the question; the measurement is what settled it. The enabled bit is not a ROW, so no organization wall scopes it: `toggleFlow` writes an in-process map keyed by flow name only, `getFlowRuntimeStates()` reads it with no caller and no organization, and 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 #5519 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 + // 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 #5519 anonymous floor answers 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/runs/:runId/resume', domain: '/automation', disposition: 'sdk', client: 'automation.resume', note: "generic, so the SUSPENDED NODE gates it (#3801): 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 since #5561: 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' }, From 00b5b7c3cf07fff0124e6f6374fb93d94fb78863 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:12:31 +0000 Subject: [PATCH 3/6] chore(changeset): runtime patch for the mounted flow clone door Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/20676-mount-flow-clone.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/20676-mount-flow-clone.md diff --git a/.changeset/20676-mount-flow-clone.md b/.changeset/20676-mount-flow-clone.md new file mode 100644 index 00000000000..85c880c041d --- /dev/null +++ b/.changeset/20676-mount-flow-clone.md @@ -0,0 +1,20 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): `POST /api/v1/automation/:name/clone` is served over HTTP + +Clause-②: no + +Cloning a flow under a new machine name (ADR-0126 §7.1) is how an admin customizes a packaged +flow whose base is locked. The runtime implemented the clone, but the dispatcher never mounted +its route, so every clone answered `404 ENDPOINT_NOT_FOUND` before the request reached it: from +the API, and from the Clone dialog on Setup's packaged-automation page, for every caller and +every body. + +The route is now mounted beside `POST /automation/:name/toggle`, at `/api/v1/automation/:name/clone` +and, when environment scoping is enabled, at `/api/v1/environments/:environmentId/automation/:name/clone`. +It answers what the clone implementation already answered: `200 { flow, notice }` for a legal +clone, `400` for a missing or illegal `name` or `label`, `404` for an unknown source flow, +`409 RESOURCE_CONFLICT` for a name already in use, `401` for an anonymous caller and `403` for a +caller without `manage_metadata`. No request or response shape changed. From 0b1c343e0a1f169f722fda26206bc26d608aa2d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:47:49 +0000 Subject: [PATCH 4/6] fix(runtime): keep the tracker id out of the clone row's ledger note string Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- packages/runtime/src/route-ledger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index df47c018bda..c915f64abde 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -442,7 +442,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ // [#20676] 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 #5519 anonymous floor answers 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`" }, + 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/runs/:runId/resume', domain: '/automation', disposition: 'sdk', client: 'automation.resume', note: "generic, so the SUSPENDED NODE gates it (#3801): 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 since #5561: 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' }, From 5518c808c20eea78c9af61b7b6ce25af21dfdccc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 05:27:36 +0000 Subject: [PATCH 5/6] test(dogfood): re-derive the route-ledger census and matrix figures for the clone row The new POST /automation/:name/clone ledger row moves the runtime ledger's row count 81 -> 82. Re-derived by the census's own method: population and reach move together (82/82), the blind spot stays 0, and the distinct domain keys stay 21. The matrix's /automation enforcement prose now names the five isFlowAuthoringWrite routes, clone included. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- packages/qa/dogfood/test/authz-conformance.matrix.ts | 4 ++-- .../qa/dogfood/test/authz-probe-blind-spot.census.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index d9981eb47f3..6d7e54d01b0 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -25,7 +25,7 @@ // dispatcher domain files. // // The population comes from `packages/rest/src/rest-route-ledger.ts` (83 rows -// / 18 families) and `packages/runtime/src/route-ledger.ts` (81 rows / 21 +// / 18 families) and `packages/runtime/src/route-ledger.ts` (82 rows / 21 // domains) because those two are enumerated from a RUNNING server and guarded // in both directions by their own conformance tests — so a new family or // domain cannot be silently absent from them, and therefore cannot be silently @@ -241,7 +241,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ covers: ['actions:domains/actions.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/actions'], note: 'A `type: \'script\'` action body runs `isSystem: true` (elevated), so an ungated POST was an anonymous privilege-escalating WRITE, not merely an information leak — #5519 measured `POST /actions/showcase_task/showcase_mark_done/:id` answering 200 with the update applied. Internal dispatch is unaffected: this handler is a pure HTTP seam (the MCP `run_action` bridge enters through action-execution.invokeBusinessAction, declarative endpoints through the transport fallback seam with their own `authRequired` gate), so `authRequired: false` public endpoints stay public.' }, { id: 'anonymous-deny-automation', summary: 'anonymous-deny on the automation/flow surface (#2567 surface 3 / #5519)', state: 'enforced', - enforcement: 'runtime/domains/automation.ts handleAutomationRequest — shouldDenyAnonymous DOMAIN-WIDE at the top, and deliberately BEFORE the isServiceServeable probe so the 401/501 difference cannot be used to fingerprint whether a deployment mounts automation; per-route capability predicates run after this floor — `manage_metadata` for the four gated flow writes (create `POST /` / update `PUT /:name` / deregister `DELETE /:name`, #10145, plus enablement `POST /:name/toggle` since the #10243 ruling of 2026-08-23, which measured that the enabled bit is not a ROW and so reaches every organization on the deployment), all selected by the ONE `isFlowAuthoringWrite` predicate, fail-closed by construction (an absent executionContext, an absent `systemPermissions` or an empty one all refuse) and answering 403 `PERMISSION_DENIED`, with only engine `isSystem` bypassing; the run-state reads (#7900) and `resume` (#3801 / #5561) carry their own separate per-route predicates, and the execution doors (trigger / execute) sit outside all of them — including `POST /trigger/:name` for a flow literally NAMED `toggle`, which the toggle arm deliberately excludes so a name cannot cost a member its run door', + enforcement: 'runtime/domains/automation.ts handleAutomationRequest — shouldDenyAnonymous DOMAIN-WIDE at the top, and deliberately BEFORE the isServiceServeable probe so the 401/501 difference cannot be used to fingerprint whether a deployment mounts automation; per-route capability predicates run after this floor — `manage_metadata` for the five gated flow writes (create `POST /` / update `PUT /:name` / deregister `DELETE /:name`, #10145, plus enablement `POST /:name/toggle` since the #10243 ruling of 2026-08-23, which measured that the enabled bit is not a ROW and so reaches every organization on the deployment, plus the ADR-0126 §7.1 clone `POST /:name/clone`, which registers flow metadata at environment scope exactly as create does), all selected by the ONE `isFlowAuthoringWrite` predicate, fail-closed by construction (an absent executionContext, an absent `systemPermissions` or an empty one all refuse) and answering 403 `PERMISSION_DENIED`, with only engine `isSystem` bypassing; the run-state reads (#7900) and `resume` (#3801 / #5561) carry their own separate per-route predicates, and the execution doors (trigger / execute) sit outside all of them — including `POST /trigger/:name` for a flow literally NAMED `toggle`, which the toggle arm deliberately excludes so a name cannot cost a member its run door', proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', // [2026-08-31] Ledger granularity for the same DOMAIN-WIDE gate named // above — the property the note already relies on ("gating the DOMAIN diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index 32397f74478..80ce3ebc33e 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -106,7 +106,7 @@ // `RestServer.getRoutes()` on a booted server and guarded per route by // `rest-route-ledger.conformance.test.ts`. It reaches all 17 registrars; // this table reaches 1. -// `packages/runtime/src/route-ledger.ts`: 81 rows over 21 domains. Its +// `packages/runtime/src/route-ledger.ts`: 82 rows over 21 domains. Its // machine contract is DOMAIN-level, by live registry introspection // (`domainRegistry.list()`), the per-route rows being documentation. It // covers all 15 `async handle*(` methods in `http-dispatcher.ts` and all @@ -362,11 +362,15 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // route (door ④ — the list is `GET /meta/flow`). It carried // `domain: '/automation'`, a key other rows still carry, so `reachable` // moves with `population`, `blindSpot` stays 0 and `keys` stays 21. - population: 81, - reachable: 81, + // [#20676] 81 -> 82: the `POST /automation/:name/clone` row arrived with its + // mount (ADR-0126 §7.1). It carries `domain: '/automation'`, an EXISTING + // key, so `reachable` moves with `population`, `blindSpot` stays 0 and + // `keys` stays 21 (21 distinct domains before and after, re-derived). + population: 82, + reachable: 82, blindSpot: 0, populationRule: 'ledger rows inside ROUTE_LEDGER; reachable = rows carrying a `domain` (each distinct value mints a key)', - controls: { "route: '": 81, "domain: '": 81, RouteLedgerEntry: 2 }, + controls: { "route: '": 82, "domain: '": 82, RouteLedgerEntry: 2 }, note: 'The dispatcher half. Its machine contract is DOMAIN-level by live registry introspection ' + '(domainRegistry.list()), guarded in BOTH directions by route-ledger.conformance.test.ts: every ' + From ab7d501509f54d8b1325a938f67aafdde67ecef4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 05:49:16 +0000 Subject: [PATCH 6/6] test(runtime): the clone-mount control drives the execution door; the toggle row says what toggleFlow writes The scoped-mount pin's positive control now POSTs /:name/trigger instead of /:name/toggle: same shape and same domain-wide anonymous floor, outside every authoring gate, so its answer does not depend on the toggle door's in-flight semantics. The toggle row's note no longer claims toggleFlow writes an in-process map only: it writes the deployment-wide activation ledger first. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- ...-plugin.automation-clone-mount.integration.test.ts | 11 ++++++++--- packages/runtime/src/route-ledger.ts | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.ts b/packages/runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.ts index 33cc53381ae..19780ad473a 100644 --- a/packages/runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.ts +++ b/packages/runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.ts @@ -90,9 +90,14 @@ function transportRefused(r: { status: number; body: any }): boolean { } describe('#20676 — the discriminator, measured in both directions', () => { - it('POSITIVE CONTROL: the sibling `/:name/toggle` mount answers from the domain', async () => { - const r = await post(`${PREFIX}/automation/${FLOW}/toggle`); - expect(dispatcherAnswered(r), `toggle -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true); + // The control is the EXECUTION door, not `/:name/toggle`, on purpose: it has + // the same two-segment POST shape and the same domain-wide anonymous floor, + // and it sits outside every authoring gate, so its answer here does not + // depend on what the toggle door does to a given flow. The control proves + // one thing only — a mounted `/automation/:name/VERB` reaches `dispatch()`. + it('POSITIVE CONTROL: the sibling `/:name/trigger` mount answers from the domain', async () => { + const r = await post(`${PREFIX}/automation/${FLOW}/trigger`); + expect(dispatcherAnswered(r), `trigger -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true); }, 60_000); it('NEGATIVE CONTROL: an unmounted sibling segment answers the transport, not the domain', async () => { diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index c915f64abde..1b7145270bc 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -438,7 +438,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'GET /automation/_status', domain: '/automation', disposition: 'sdk', client: 'automation.getRuntimeStatus' }, { 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 #10243 ruling (2026-08-23) `manage_metadata` gates it — the same `isFlowAuthoringWrite` door as `POST /automation` above, NOT a fourth copy of the policy. #10145 deliberately left this one out as engine state and filed the question; the measurement is what settled it. The enabled bit is not a ROW, so no organization wall scopes it: `toggleFlow` writes an in-process map keyed by flow name only, `getFlowRuntimeStates()` reads it with no caller and no organization, and 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 #5519 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`" }, + note: "enablement, and since the #10243 ruling (2026-08-23) `manage_metadata` gates it — the same `isFlowAuthoringWrite` door as `POST /automation` above, NOT a fourth copy of the policy. #10145 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 #5519 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 // nothing mounted it until #20676, so the row and the mount arrive together. { route: 'POST /automation/:name/clone', domain: '/automation', disposition: 'server-only',