From 6d32dc2276a4415cf1b8d4e181cc1dc71efc7154 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 15:14:00 +0000 Subject: [PATCH 1/3] feat(automation): write doors answer the canonicalized parsed flow (#12206, Option A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /automation and PUT /automation/:name relay the FlowParsed that registerFlow now returns — the same shape GET serves. SDK binds Promise on create/update; response schemas conformant; UpdateFlowRequestSchema requires the complete definition; SDK unit tests get registrable bodies; changeset carries the migration note. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KX8wnyjStaZcuMyAMNsy3N --- .changeset/automation-write-doors-parsed.md | 12 ++ packages/client/exported-any-returns.json | 4 +- ...utomation-write-door-parsed-answer.test.ts | 174 ++++++++++++++++++ packages/client/src/client.test.ts | 53 +++++- packages/client/src/index.ts | 24 +-- .../client/src/return-type-precision.test.ts | 8 + .../automation-put-post-error-parity.test.ts | 3 + packages/runtime/src/domains/automation.ts | 20 +- .../src/canonicalize-stored-flow.test.ts | 18 ++ .../services/service-automation/src/engine.ts | 8 +- .../spec/src/api/automation-api.zod.test.ts | 25 ++- packages/spec/src/api/automation-api.zod.ts | 24 ++- .../src/contracts/automation-service.test.ts | 6 +- .../spec/src/contracts/automation-service.ts | 14 +- 14 files changed, 356 insertions(+), 37 deletions(-) create mode 100644 .changeset/automation-write-doors-parsed.md create mode 100644 packages/client/src/automation-write-door-parsed-answer.test.ts diff --git a/.changeset/automation-write-doors-parsed.md b/.changeset/automation-write-doors-parsed.md new file mode 100644 index 0000000000..124833b2a6 --- /dev/null +++ b/.changeset/automation-write-doors-parsed.md @@ -0,0 +1,12 @@ +--- +"@objectstack/spec": minor +"@objectstack/runtime": minor +"@objectstack/client": minor +"@objectstack/service-automation": minor +--- + +Automation write doors answer the canonicalized (parsed) flow (#12206, Option A — maintainer ruling 2026-08-26). + +`POST /api/v1/automation` and `PUT /api/v1/automation/:name` now answer the canonicalized, parsed flow the engine stored — the same shape `GET /api/v1/automation/:name` already answers — instead of echoing the caller's own pre-parse request bytes. `IAutomationService.registerFlow` returns that `FlowParsed` (previously `void`), and the SDK's `client.automation.create` / `client.automation.update` bind `Promise` (previously deliberate `Promise`). `CreateFlowResponseSchema` / `UpdateFlowResponseSchema` are now conformant with the real wire body, and `UpdateFlowRequestSchema.definition` requires the complete flow definition the engine actually requires (its former `.partial()` declared a partial-update capability nothing implements; a real partial update would be its own feature). + +**Migration note (behaviour change on a published SDK surface).** A caller that read the write response back gets the canonicalized flow rather than its own bytes: schema defaults are materialized (`version`, `status`, `runAs`, per-edge `type` / `isDefault`), keys re-emit in schema order, and the PUT answer always carries `name`. The #12206 consumer survey measured zero non-test consumers of the old echo across objectstack and objectui. The one residual risk, named verbatim from that survey: "One real TYPE change — the only shape-breaking difference in the whole measurement": a string `edge.condition` becomes the lowered CEL envelope — the `edge.condition` string → `{dialect, source}` type change, zero measured consumers. A consumer doing `typeof edge.condition === 'string'` on the write response would break; per the survey no such consumer exists in either repo (the cloud repo was not measurable and is the declared gap). Implementers of `IAutomationService.registerFlow` must now return the stored parsed flow. diff --git a/packages/client/exported-any-returns.json b/packages/client/exported-any-returns.json index 8f6af1e0e4..f77d675bad 100644 --- a/packages/client/exported-any-returns.json +++ b/packages/client/exported-any-returns.json @@ -40,8 +40,6 @@ "ObjectStackClient.auth.twoFactor.verifyTotp": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.", "ObjectStackClient.auth.twoFactor.disable": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.", "ObjectStackClient.auth.twoFactor.verifyBackupCode": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.", - "ObjectStackClient.auth.accounts.unlink": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.", - "ObjectStackClient.automation.create": "#11924 — DELIBERATE `Promise`: `POST /automation` ends `deps.success(body)`, echoing the caller's own unvalidated bytes, and `IAutomationService.registerFlow` returns nothing, so the service contract has no return shape to relay. This needs a DECISION (keep echoing, or answer the registered `FlowParsed`), not an annotation.", - "ObjectStackClient.automation.update": "#11924 — DELIBERATE `Promise`: `PUT /automation/:name` ends `deps.success(definition)` where `definition = body.definition ?? body`. Same missing contract as `automation.create`, and the two should be answered together since they are one route class." + "ObjectStackClient.auth.accounts.unlink": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope." } } diff --git a/packages/client/src/automation-write-door-parsed-answer.test.ts b/packages/client/src/automation-write-door-parsed-answer.test.ts new file mode 100644 index 0000000000..a503cc379a --- /dev/null +++ b/packages/client/src/automation-write-door-parsed-answer.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12206, Option A — ruled 2026-08-26] The two `/automation` definition-write + * doors answer the CANONICALIZED, PARSED flow the engine stored — the same + * shape `GET /automation/:name` answers — never an echo of the caller's own + * pre-parse bytes. + * + * Everything here is real end to end, on the pattern of + * `analytics-automation-json-erasure.test.ts`: the real `AutomationEngine` + * (`@objectstack/service-automation`), the real `HttpDispatcher` + * (`@objectstack/runtime`), and the real `ObjectStackClient` reading the + * result. The only stand-in is the socket: `fetch` hands the request to the + * dispatcher in-process and hands back the producer's own body untouched — + * a mocked response body here would assert this file's own assumption, which + * is exactly the mistake that let the old response schemas sit aspirational. + * + * What each leg pins: + * + * 1. the answer is NOT the echo — schema defaults the caller never wrote + * (`version`, `status`, `runAs`, per-edge `type`/`isDefault`) are + * materialized, and a string `edge.condition` is lowered to its + * `{dialect, source}` envelope (the one genuine type change the #12206 + * survey measured, zero consumers); + * 2. write ≡ read — the write door's `data` deep-equals what the read door + * then serves for the same resource, so write-then-read is stable; + * 3. the published `CreateFlowResponseSchema` / `UpdateFlowResponseSchema` + * parse the REAL wire body (inherited item ①: conformant, not + * aspirational — the first response these schemas have ever seen); + * 4. the PUT answer always carries `name`, which the old echo could omit + * (the name rode the path, not the body). + * + * Reverse verification, direction predicted BEFORE running: reverting the two + * route exits in `packages/runtime/src/domains/automation.ts` back to + * `deps.success(body)` / `deps.success(definition)` turns legs 1-4 RED (the + * echo carries no `version`, no lowered condition, and PUT's echo has no + * `name`); reverting `AutomationEngine.registerFlow` to `void` turns the + * routes' answer `undefined` and reds leg 2/3 the same way. + */ + +import { describe, it, expect } from 'vitest'; +import { AutomationEngine, InMemorySuspendedRunStore } from '@objectstack/service-automation'; +import { HttpDispatcher } from '@objectstack/runtime'; +import { CreateFlowResponseSchema, UpdateFlowResponseSchema } from '@objectstack/spec/api'; +import type { FlowParsed } from '@objectstack/spec/automation'; +import { ObjectStackClient } from './index'; + +const BASE_URL = 'http://localhost:3000'; + +/** The definition writes demand `manage_metadata` (ADR-0066 D1). */ +const CONTEXT = (): any => ({ + request: {}, + executionContext: { userId: 'usr_1', isSystem: false, systemPermissions: ['manage_metadata'] }, +}); + +/** A raw authored condition string — what the schema lowers to a CEL envelope. */ +const RAW_CONDITION = "record.status == 'approved'"; + +/** + * A raw authored flow body, the way a real HTTP caller writes one: no + * `version`, no `status`, no `runAs`, no per-edge `type`/`isDefault`, and a + * bare STRING `edge.condition`. Every one of those is a delta the parsed + * answer materializes — which is what makes this fixture able to tell the + * canonicalized answer apart from an echo. + */ +const RAW_DEFINITION = { + label: 'Write Door Flow', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end', condition: RAW_CONDITION }], +}; + +function producerBackedClient() { + const engine = new AutomationEngine( + { info() {}, warn() {}, error() {}, debug() {}, child() { return this; } } as never, + new InMemorySuspendedRunStore(), + ); + const services: Record = { automation: engine }; + const resolve = (name: string): unknown => services[name]; + const kernel: any = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { getService: resolve }, + }; + const dispatcher = new HttpDispatcher(kernel); + + /** The last RAW wire body — the envelope `unwrapResponse` strips, kept so + * the response schemas can be parsed against what really crossed the wire. */ + const wire: { last: unknown } = { last: undefined }; + + const fetchImpl = async (url: string, init: RequestInit = {}): Promise => { + const parsed = new URL(String(url)); + const method = init.method ?? 'GET'; + const body = init.body ? JSON.parse(String(init.body)) : undefined; + const query = Object.fromEntries(parsed.searchParams); + const dispatched = await dispatcher.handleAutomation( + parsed.pathname.slice('/api/v1/automation'.length), method, body, CONTEXT(), query); + expect(dispatched.handled, `the dispatcher must serve ${method} ${parsed.pathname}`).toBe(true); + const status = dispatched.response?.status ?? 500; + wire.last = dispatched.response?.body; + return { + ok: status >= 200 && status < 300, + status, + statusText: String(status), + headers: new Headers(), + json: async () => dispatched.response?.body, + }; + }; + + const client = new ObjectStackClient({ baseUrl: BASE_URL, fetch: fetchImpl as any }); + return { client, engine, wire }; +} + +describe('#12206 — POST /automation answers the canonicalized parsed flow, not the echo', () => { + it('materializes schema defaults, lowers edge.condition, matches the read door, and conforms to CreateFlowResponseSchema', async () => { + const { client, wire } = producerBackedClient(); + + const answered: FlowParsed = await client.automation.create('wd_flow', RAW_DEFINITION); + + // ① NOT the echo: the caller never wrote any of these. + expect(answered.name).toBe('wd_flow'); + expect(answered.version).toBe(1); + expect(answered.status).toBe('draft'); + expect((answered as any).runAs).toBe('user'); + expect(answered.edges[0]).toMatchObject({ type: 'default', isDefault: false }); + // The one genuine type change the survey measured: string condition → + // lowered `{dialect, source}` envelope. + expect(answered.edges[0].condition).toEqual({ dialect: 'cel', source: RAW_CONDITION }); + + // ③ Inherited item ①: the published response schema parses the REAL + // wire envelope — conformant, no longer aspirational. + const envelope = CreateFlowResponseSchema.parse(wire.last); + expect(envelope.success).toBe(true); + expect(envelope.data.name).toBe('wd_flow'); + + // ② Write ≡ read: the write door answered exactly what the read door + // now serves for the same resource. + const read = await client.automation.get('wd_flow'); + expect(answered).toEqual(read); + }); +}); + +describe('#12206 — PUT /automation/:name answers the canonicalized parsed flow, not the echo', () => { + it('always carries name, matches the read door, and conforms to UpdateFlowResponseSchema', async () => { + const { client, wire } = producerBackedClient(); + await client.automation.create('wd_flow', RAW_DEFINITION); + + // The SDK sends `{ definition }`; the engine requires a COMPLETE + // definition (inherited item ② — `UpdateFlowRequestSchema` no longer + // claims a partial-update capability nothing implements). + const updated = { name: 'wd_flow', ...RAW_DEFINITION, label: 'Write Door Flow v2' }; + const answered: FlowParsed = await client.automation.update('wd_flow', updated); + + // ④ The old PUT echo answered `body.definition ?? body`, which could + // omit `name` entirely; the parsed answer always carries it. + expect(answered.name).toBe('wd_flow'); + expect(answered.label).toBe('Write Door Flow v2'); + // ① NOT the echo — same materialized defaults as the POST door. + expect(answered.version).toBe(1); + expect(answered.edges[0].condition).toEqual({ dialect: 'cel', source: RAW_CONDITION }); + + // ③ Inherited item ①, update half. + const envelope = UpdateFlowResponseSchema.parse(wire.last); + expect(envelope.success).toBe(true); + expect(envelope.data.label).toBe('Write Door Flow v2'); + + // ② Write ≡ read. + const read = await client.automation.get('wd_flow'); + expect(answered).toEqual(read); + }); +}); diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 3f70ca54f3..05154d1d2d 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, vi } from 'vitest'; // classes themselves left two unused bindings (TS6133) the moment this file // entered a tsc program (#5449). import { WELL_KNOWN_CAPABILITY_KEYS } from '@objectstack/spec/api'; +import { FlowSchema } from '@objectstack/spec/automation'; import { ObjectStackClient, createQuery, createFilter } from './index'; import type { QueryOptions, QueryOptionsV2 } from './index'; @@ -1264,30 +1265,68 @@ describe('ObjectStackClient.automation', () => { expect(result.name).toBe('my_flow'); }); - it('should create a flow', async () => { + // [#12206, inherited item ③] These two bodies used to be `{ label }` + // fragments — 400 against a real server (`registerFlow` runs + // `FlowSchema.parse`), passing only because `fetch` is mocked. They are + // now REGISTRABLE, and each test pins that with a real parse of the exact + // wire body the SDK sends, so the fixture cannot silently rot again. + it('should create a flow (registrable body)', async () => { + const definition = { + label: 'New Flow', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }; + // `create` sends `{ name, ...definition }` — that merged object is + // what the engine parses; prove it is registrable. + expect(() => FlowSchema.parse({ name: 'new_flow', ...definition })).not.toThrow(); + const { client, fetchMock } = createMockClient({ success: true, - data: { name: 'new_flow' }, + data: FlowSchema.parse({ name: 'new_flow', ...definition }), }); - await client.automation.create('new_flow', { label: 'New' }); + const result = await client.automation.create('new_flow', definition); expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:3000/api/v1/automation', expect.objectContaining({ method: 'POST' }), ); - }); + // The door answers the canonicalized parsed flow (#12206, Option A): + // schema defaults the caller never wrote are materialized. + expect(result.name).toBe('new_flow'); + expect(result.version).toBe(1); + expect(result.status).toBe('draft'); + }); + + it('should update a flow (registrable body)', async () => { + const definition = { + name: 'my_flow', + label: 'Updated', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }; + // `update` sends `{ definition }`; the engine parses the definition. + expect(() => FlowSchema.parse(definition)).not.toThrow(); - it('should update a flow', async () => { const { client, fetchMock } = createMockClient({ success: true, - data: { name: 'my_flow', label: 'Updated' }, + data: FlowSchema.parse(definition), }); - await client.automation.update('my_flow', { label: 'Updated' }); + const result = await client.automation.update('my_flow', definition); expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:3000/api/v1/automation/my_flow', expect.objectContaining({ method: 'PUT' }), ); + expect(result.name).toBe('my_flow'); + expect(result.label).toBe('Updated'); }); it('should delete a flow', async () => { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 6a0ff280d5..ad080cbc09 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -3913,15 +3913,13 @@ export class ObjectStackClient { /** * Create (register) a new flow * - * [#8140] ⛔ `Promise` is DELIBERATE here, and it is a missing - * CONTRACT rather than a missing annotation. The route ends - * `deps.success(body)` — the request body, echoed — and - * `IAutomationService.registerFlow(name, definition: unknown): void` - * returns nothing, so no published type describes what comes back. - * Naming `Flow` would be a claim about the REQUEST that no validation - * backs. Authoring the response contract is `packages/spec`'s call. + * [#12206, Option A] Answers the canonicalized PARSED flow the engine + * stored (schema defaults materialized, `edge.condition` strings + * lowered to their `{dialect, source}` envelopes) — the same shape + * `get` answers, so a write-then-read on the resource is stable. + * `CreateFlowResponseSchema` declares the wire envelope this unwraps. */ - create: async (name: string, definition: any): Promise => { + create: async (name: string, definition: any): Promise => { const route = this.getRoute('automation'); const res = await this.fetch(`${this.baseUrl}${route}`, { method: 'POST', @@ -3933,11 +3931,13 @@ export class ObjectStackClient { /** * Update an existing flow * - * [#8140] ⛔ `Promise` is DELIBERATE — same missing contract as - * `create` above: the route ends `deps.success(definition)`, echoing - * what was sent. + * [#12206, Option A] Same contract as `create` above: answers the + * canonicalized parsed flow the engine stored — always carrying `name`, + * which the old echo could omit on PUT. The engine requires a COMPLETE + * definition (`UpdateFlowRequestSchema`); partial update is not + * implemented. */ - update: async (name: string, definition: any): Promise => { + update: async (name: string, definition: any): Promise => { const route = this.getRoute('automation'); const res = await this.fetch(`${this.baseUrl}${route}/${name}`, { method: 'PUT', diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 38ae0b4c80..f31452952f 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -129,6 +129,14 @@ export async function returnTypePrecisionPins(): Promise { expectTypeOf(await client.automation.execute('flow_a')).toEqualTypeOf(); expectTypeOf(await client.automation.getRun('flow_a', 'run_1')).toEqualTypeOf(); + // [#12206, Option A] The two write doors answer the canonicalized parsed + // flow — the single true return type the read door already binds. These + // pins retire the last two DELIBERATE `Promise` declarations of the + // automation family (`exported-any-returns.json` entries deleted in the + // same change). + expectTypeOf(await client.automation.create('flow_a', {})).toEqualTypeOf(); + expectTypeOf(await client.automation.update('flow_a', {})).toEqualTypeOf(); + // Explicit type arguments still work for a LEGITIMATE narrowing — the // parameter was kept, its default moved off `any`, and a constraint was // added. This is the compatibility half of the narrowing. diff --git a/packages/runtime/src/domains/automation-put-post-error-parity.test.ts b/packages/runtime/src/domains/automation-put-post-error-parity.test.ts index d1f5a3367f..9c85dc81c7 100644 --- a/packages/runtime/src/domains/automation-put-post-error-parity.test.ts +++ b/packages/runtime/src/domains/automation-put-post-error-parity.test.ts @@ -93,6 +93,9 @@ function makeDispatcher() { } } registered.set(name, parsed); + // [#12206] Faithful to the real engine: `registerFlow` answers the + // canonicalized parsed flow it stored, which the doors relay. + return parsed; }, getFlow: async (name: string) => registered.get(name) ?? null, }; diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 6c11076c75..0ff5f6bdbc 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -1133,15 +1133,21 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // that is not a permission denial, so a transport calling it // directly would otherwise get an exception where every other // refusal on this domain hands back a response. + // [#12206, Option A] The door answers the canonicalized PARSED + // flow `registerFlow` stored (schema defaults materialized, + // `edge.condition` strings lowered to their envelopes) — the same + // shape `GET /automation/:name` serves — never an echo of the + // caller's own pre-parse bytes. + let registered; try { - automationService.registerFlow(body.name, body); + registered = automationService.registerFlow(body.name, body); } catch (e) { return { handled: true, response: deps.errorFromThrown(flowDefinitionRefusal(e), VALIDATION_FAILED_STATUS), }; } - return { handled: true, response: deps.success(body) }; + return { handled: true, response: deps.success(registered) }; } } @@ -1851,15 +1857,21 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // same route-agnostic `flowDefinitionRefusal` helper POST // uses above, so the two doors cannot disagree about the // class of an identical refusal (#8055 wired POST only). + // [#12206, Option A] Same as the POST door above: answer the + // canonicalized parsed flow the engine stored, not the + // caller's echo. This also closes the old PUT quirk where the + // echoed `definition` could lack `name` (the name rode the + // path) — the parsed flow always carries it. + let registered; try { - automationService.registerFlow(name, definition); + registered = automationService.registerFlow(name, definition); } catch (e) { return { handled: true, response: deps.errorFromThrown(flowDefinitionRefusal(e), VALIDATION_FAILED_STATUS), }; } - return { handled: true, response: deps.success(definition) }; + return { handled: true, response: deps.success(registered) }; } } diff --git a/packages/services/service-automation/src/canonicalize-stored-flow.test.ts b/packages/services/service-automation/src/canonicalize-stored-flow.test.ts index b241807dfe..59dae1287c 100644 --- a/packages/services/service-automation/src/canonicalize-stored-flow.test.ts +++ b/packages/services/service-automation/src/canonicalize-stored-flow.test.ts @@ -156,4 +156,22 @@ describe('registerFlow still behaves identically (#4454 refactor)', () => { // …and the defaults it needs. expect(flow.version).toBeDefined(); }); + + // [#12206, Option A] `registerFlow` no longer returns void: it answers the + // canonicalized PARSED flow it stored — the very object `getFlow` serves — + // which is what the `/automation` write doors relay to the caller. + // Reverse verification (predicted before running): reverting the `return + // parsed` in `registerFlow` makes `returned` undefined and reds all three + // assertions. + it('returns the canonicalized parsed flow it stored — the same object getFlow serves', async () => { + const engine = new AutomationEngine(silentLogger); + const raw = legacyFlow(); + const returned = engine.registerFlow('sweep_stale', raw); + + expect(returned).toBe(await engine.getFlow('sweep_stale')); + // Parsed, not the caller's bytes: the converted config and the + // materialized default are both visible on the returned value. + expect((returned as any).nodes[1].config.filter).toEqual({ status: 'stale' }); + expect(returned.version).toBe(1); + }); }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index eba2d9a12c..1a3bf67f43 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -2638,7 +2638,7 @@ export class AutomationEngine implements IAutomationService { }; } - registerFlow(name: string, definition: unknown): void { + registerFlow(name: string, definition: unknown): FlowParsed { // One canonicalization policy, shared with the stored-row migration so // the two can never disagree about what "canonical" means (#4454). // Execution takes the parsed shape (schema defaults materialized). @@ -2728,6 +2728,12 @@ export class AutomationEngine implements IAutomationService { if (this.isFlowEnabled(name)) { this.activateFlowTrigger(name); } + + // #12206 (Option A) — hand the caller the canonicalized flow this + // registration stored: the same object `this.flows` now holds and + // {@link getFlow} answers. The `/automation` write doors relay it, so + // a write answers the shape the subsequent read serves. + return parsed; } unregisterFlow(name: string): void { diff --git a/packages/spec/src/api/automation-api.zod.test.ts b/packages/spec/src/api/automation-api.zod.test.ts index d9d85f592c..d3f464850b 100644 --- a/packages/spec/src/api/automation-api.zod.test.ts +++ b/packages/spec/src/api/automation-api.zod.test.ts @@ -237,10 +237,31 @@ describe('CreateFlowResponseSchema', () => { // ========================================== describe('UpdateFlowRequestSchema', () => { - it('should accept a partial update', () => { - const result = UpdateFlowRequestSchema.parse({ + // [#12206, inherited item ②] The old `.partial()` declared a partial-update + // capability nothing implements: the engine's `registerFlow` runs + // `FlowSchema.parse` on the definition, so a bare `{ label }` has always + // been a 400 against a real server. The request schema now requires the + // complete definition the engine actually requires. + it('should reject a partial definition — the engine requires a complete flow', () => { + expect(() => UpdateFlowRequestSchema.parse({ name: 'my_flow', definition: { label: 'Updated Label' }, + })).toThrow(); + }); + + it('should accept a complete flow definition', () => { + const result = UpdateFlowRequestSchema.parse({ + name: 'my_flow', + definition: { + name: 'my_flow', + label: 'Updated Label', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }, }); expect(result.name).toBe('my_flow'); expect(result.definition.label).toBe('Updated Label'); diff --git a/packages/spec/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts index 8a12f42594..6cb3841caa 100644 --- a/packages/spec/src/api/automation-api.zod.ts +++ b/packages/spec/src/api/automation-api.zod.ts @@ -136,9 +136,15 @@ export type CreateFlowRequestParsed = z.infer; /** * Response after creating a flow. + * + * `data` is the CANONICALIZED flow the engine stored (#12206, Option A): + * `FlowSchema.parse` output with schema defaults materialized (`version`, + * `status`, `runAs`, per-edge `type`/`isDefault`) and `edge.condition` + * strings lowered to their `{dialect, source}` envelopes — the same shape + * `GET /api/automation/:name` answers, never an echo of the request bytes. */ export const CreateFlowResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: FlowSchema.describe('The created flow definition'), + data: FlowSchema.describe('The created flow, canonicalized — the parsed shape the engine stored, identical to what a subsequent GET answers'), })); export type CreateFlowResponse = z.input; /** Post-parse shape of {@link CreateFlowResponse} — defaults applied, transforms run (ADR-0122). */ @@ -151,11 +157,17 @@ export type CreateFlowResponseParsed = z.infer; /** * Request body for updating an existing flow. * + * `definition` is the COMPLETE flow definition (#12206, inherited item ②): + * the engine's `registerFlow` runs `FlowSchema.parse` on it, so a partial + * body (e.g. a bare `{ label }`) is a 400 against a real server — the old + * `.partial()` here declared a partial-update capability nothing implements. + * A real partial-update capability would be its own feature card. + * * @example PUT /api/automation/approval_flow - * { label: 'Updated Label', nodes: [...], edges: [...] } + * { name: 'approval_flow', definition: { name: 'approval_flow', label: 'Approval Flow', type: 'autolaunched', nodes: [...], edges: [...] } } */ export const UpdateFlowRequestSchema = lazySchema(() => AutomationFlowPathParamsSchema.extend({ - definition: FlowSchema.partial().describe('Partial flow definition to update'), + definition: FlowSchema.describe('Complete flow definition to store — the engine requires a full flow; partial update is not implemented'), })); export type UpdateFlowRequest = z.input; /** Post-parse shape of {@link UpdateFlowRequest} — defaults applied, transforms run (ADR-0122). */ @@ -163,9 +175,13 @@ export type UpdateFlowRequestParsed = z.infer; /** * Response after updating a flow. + * + * `data` is the canonicalized flow the engine stored — see + * {@link CreateFlowResponseSchema}; the two write doors answer the same + * shape (#12206, Option A). Unlike the old echo, it always carries `name`. */ export const UpdateFlowResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: FlowSchema.describe('The updated flow definition'), + data: FlowSchema.describe('The updated flow, canonicalized — the parsed shape the engine stored, identical to what a subsequent GET answers'), })); export type UpdateFlowResponse = z.input; /** Post-parse shape of {@link UpdateFlowResponse} — defaults applied, transforms run (ADR-0122). */ diff --git a/packages/spec/src/contracts/automation-service.test.ts b/packages/spec/src/contracts/automation-service.test.ts index b258a52070..62a001e2f5 100644 --- a/packages/spec/src/contracts/automation-service.test.ts +++ b/packages/spec/src/contracts/automation-service.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import type { IAutomationService, AutomationResult } from './automation-service'; import type { FlowParsed } from '../automation/flow.zod'; +import { FlowSchema } from '../automation/flow.zod'; import type { ExecutionLog } from '../automation/execution.zod'; import type { ConnectorDescriptor } from '../integration/connector-descriptor'; @@ -19,7 +20,10 @@ describe('Automation Service Contract', () => { const service: IAutomationService = { execute: async () => ({ success: true }), listFlows: async () => [], - registerFlow: (_name, _definition) => {}, + // [#12206] `registerFlow` answers the canonicalized parsed flow it + // stored — the same object `getFlow` serves; parsing IS the minimal + // conforming implementation. + registerFlow: (_name, definition) => FlowSchema.parse(definition), unregisterFlow: (_name) => {}, getFlow: async (_name) => null, toggleFlow: async (_name, _enabled) => {}, diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 03b85a567f..d32816bef0 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -400,11 +400,19 @@ export interface IAutomationService { listFlows(): Promise; /** - * Register a flow definition + * Register a flow definition. + * + * Returns the canonicalized, PARSED flow it stored — `FlowSchema.parse` + * output with schema defaults materialized, the same object a subsequent + * {@link getFlow} answers. This is what the `/automation` write doors + * relay to the caller (#12206, Option A): the caller learns what the + * engine actually stored, not an echo of its own request bytes. + * * @param name - Flow name (snake_case) - * @param definition - Flow definition object + * @param definition - Flow definition object (raw, pre-parse) + * @returns The canonicalized parsed flow as stored */ - registerFlow?(name: string, definition: unknown): void; + registerFlow?(name: string, definition: unknown): FlowParsed; /** * Canonicalize a flow definition WITHOUT registering it (#4454). From 6dc098f9917a91d7005772495a3bf6bc34a604ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 15:45:50 +0000 Subject: [PATCH 2/3] test(runtime): the write-door pin asserts the parsed answer, double faithful to registerFlow's return (#12206) Predicted red observed red: the old echo pin failed against the new answer (data undefined from a void double); the double now returns the parsed flow and the pin asserts the materialized version default. Claude-Session: https://claude.ai/code/session_01KX8wnyjStaZcuMyAMNsy3N --- .../src/domains/automation-register-error-class.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/domains/automation-register-error-class.test.ts b/packages/runtime/src/domains/automation-register-error-class.test.ts index ab2544c22c..a9ab36e23e 100644 --- a/packages/runtime/src/domains/automation-register-error-class.test.ts +++ b/packages/runtime/src/domains/automation-register-error-class.test.ts @@ -123,6 +123,9 @@ function makeDispatcher(options?: { registerFlow?: (name: string, definition: un } registered.set(name, parsed); flows.set(name, parsed); + // [#12206] Faithful to the real engine: `registerFlow` answers + // the canonicalized parsed flow it stored, which the doors relay. + return parsed; })), getFlow: vi.fn(async (name: string) => flows.get(name) ?? null), toggleFlow: vi.fn(async (name: string) => { @@ -349,8 +352,10 @@ describe('#8055 — what must not change', () => { expect(result.response?.body?.success).toBe(true); expect(spies.registerFlow).toHaveBeenCalledWith('welcome_flow', WELL_FORMED); expect(registered.has('welcome_flow')).toBe(true); - // The definition is echoed back unchanged, as it always was. - expect(result.response?.body?.data ?? result.response?.body).toMatchObject({ name: 'welcome_flow' }); + // [#12206, Option A] The door answers the canonicalized PARSED flow + // the service stored — not an echo of the caller's bytes: `version` + // is a schema default WELL_FORMED never wrote. + expect(result.response?.body?.data).toMatchObject({ name: 'welcome_flow', version: 1 }); }); it('the #3899 body checks still refuse BEFORE the engine is asked', async () => { From 8d2d0c43bb55f8a675b2a45e118966bbd903bab9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 16:08:46 +0000 Subject: [PATCH 3/3] docs(spec): regenerate automation-api reference for the #12206 contract text Claude-Session: https://claude.ai/code/session_01KX8wnyjStaZcuMyAMNsy3N --- content/docs/references/api/automation-api.mdx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 91cad7f6e8..1dd65998ef 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -180,7 +180,7 @@ const result = AutomationApiErrorCode.parse(data); | **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?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The created flow definition | +| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The created flow, canonicalized — the parsed shape the engine stored, identical to what a subsequent GET answers | ### Nested Shape: `CreateFlowResponse.error` @@ -624,24 +624,24 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Flow machine name (snake_case) | -| **definition** | `{ name?: string; label?: string; description?: string; successMessage?: string; … }` | ✅ | Partial flow definition to update | +| **definition** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | Complete flow definition to store — the engine requires a full flow; partial update is not implemented | ### Nested Shape: `UpdateFlowRequest.definition` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | optional | Machine name | -| **label** | `string` | optional | Flow label | +| **name** | `string` | ✅ | Machine name | +| **label** | `string` | ✅ | Flow label | | **description** | `string` | optional | | | **successMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of a generic "Done". | | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | | **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | optional | Flow type | +| **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | -| **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | optional | Flow nodes | -| **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | optional | Flow connections | +| **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | +| **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | @@ -666,7 +666,7 @@ const result = AutomationApiErrorCode.parse(data); | **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?: number; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The updated flow definition | +| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The updated flow, canonicalized — the parsed shape the engine stored, identical to what a subsequent GET answers | ### Nested Shape: `UpdateFlowResponse.error`