Skip to content

Commit 803eaab

Browse files
feat(automation): write doors answer the canonicalized parsed flow (#12206, Option A) (#13525)
* feat(automation): write doors answer the canonicalized parsed flow (#12206, Option A) POST /automation and PUT /automation/:name relay the FlowParsed that registerFlow now returns — the same shape GET serves. SDK binds Promise<FlowParsed> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KX8wnyjStaZcuMyAMNsy3N * 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 * docs(spec): regenerate automation-api reference for the #12206 contract text Claude-Session: https://claude.ai/code/session_01KX8wnyjStaZcuMyAMNsy3N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9688f58 commit 803eaab

16 files changed

Lines changed: 371 additions & 47 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/client": minor
5+
"@objectstack/service-automation": minor
6+
---
7+
8+
Automation write doors answer the canonicalized (parsed) flow (#12206, Option A — maintainer ruling 2026-08-26).
9+
10+
`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<FlowParsed>` (previously deliberate `Promise<any>`). `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).
11+
12+
**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.

content/docs/references/api/automation-api.mdx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ const result = AutomationApiErrorCode.parse(data);
180180
| **success** | `boolean` || Operation success status |
181181
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false |
182182
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
183-
| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` || The created flow definition |
183+
| **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 |
184184

185185
### Nested Shape: `CreateFlowResponse.error`
186186

@@ -624,24 +624,24 @@ const result = AutomationApiErrorCode.parse(data);
624624
| Property | Type | Required | Description |
625625
| :--- | :--- | :--- | :--- |
626626
| **name** | `string` || Flow machine name (snake_case) |
627-
| **definition** | `{ name?: string; label?: string; description?: string; successMessage?: string; … }` || Partial flow definition to update |
627+
| **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 |
628628

629629
### Nested Shape: `UpdateFlowRequest.definition`
630630

631631
| Property | Type | Required | Description |
632632
| :--- | :--- | :--- | :--- |
633-
| **name** | `string` | optional | Machine name |
634-
| **label** | `string` | optional | Flow label |
633+
| **name** | `string` | | Machine name |
634+
| **label** | `string` | | Flow label |
635635
| **description** | `string` | optional | |
636636
| **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". |
637637
| **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. |
638638
| **version** | `integer` | optional (default: `1`) | Version number |
639639
| **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status |
640640
| **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. |
641-
| **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | optional | Flow type |
641+
| **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | | Flow type |
642642
| **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables |
643-
| **nodes** | `{ id: string; type: string; label: string; config?: Record<string, any>; … }[]` | optional | Flow nodes |
644-
| **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | optional | Flow connections |
643+
| **nodes** | `{ id: string; type: string; label: string; config?: Record<string, any>; … }[]` | | Flow nodes |
644+
| **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | | Flow connections |
645645
| **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. |
646646
| **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. |
647647
| **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);
666666
| **success** | `boolean` || Operation success status |
667667
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false |
668668
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
669-
| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` || The updated flow definition |
669+
| **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 |
670670

671671
### Nested Shape: `UpdateFlowResponse.error`
672672

packages/client/exported-any-returns.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,6 @@
4040
"ObjectStackClient.auth.twoFactor.verifyTotp": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
4141
"ObjectStackClient.auth.twoFactor.disable": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
4242
"ObjectStackClient.auth.twoFactor.verifyBackupCode": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
43-
"ObjectStackClient.auth.accounts.unlink": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
44-
"ObjectStackClient.automation.create": "#11924 — DELIBERATE `Promise<any>`: `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.",
45-
"ObjectStackClient.automation.update": "#11924 — DELIBERATE `Promise<any>`: `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."
43+
"ObjectStackClient.auth.accounts.unlink": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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."
4644
}
4745
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#12206, Option A — ruled 2026-08-26] The two `/automation` definition-write
5+
* doors answer the CANONICALIZED, PARSED flow the engine stored — the same
6+
* shape `GET /automation/:name` answers — never an echo of the caller's own
7+
* pre-parse bytes.
8+
*
9+
* Everything here is real end to end, on the pattern of
10+
* `analytics-automation-json-erasure.test.ts`: the real `AutomationEngine`
11+
* (`@objectstack/service-automation`), the real `HttpDispatcher`
12+
* (`@objectstack/runtime`), and the real `ObjectStackClient` reading the
13+
* result. The only stand-in is the socket: `fetch` hands the request to the
14+
* dispatcher in-process and hands back the producer's own body untouched —
15+
* a mocked response body here would assert this file's own assumption, which
16+
* is exactly the mistake that let the old response schemas sit aspirational.
17+
*
18+
* What each leg pins:
19+
*
20+
* 1. the answer is NOT the echo — schema defaults the caller never wrote
21+
* (`version`, `status`, `runAs`, per-edge `type`/`isDefault`) are
22+
* materialized, and a string `edge.condition` is lowered to its
23+
* `{dialect, source}` envelope (the one genuine type change the #12206
24+
* survey measured, zero consumers);
25+
* 2. write ≡ read — the write door's `data` deep-equals what the read door
26+
* then serves for the same resource, so write-then-read is stable;
27+
* 3. the published `CreateFlowResponseSchema` / `UpdateFlowResponseSchema`
28+
* parse the REAL wire body (inherited item ①: conformant, not
29+
* aspirational — the first response these schemas have ever seen);
30+
* 4. the PUT answer always carries `name`, which the old echo could omit
31+
* (the name rode the path, not the body).
32+
*
33+
* Reverse verification, direction predicted BEFORE running: reverting the two
34+
* route exits in `packages/runtime/src/domains/automation.ts` back to
35+
* `deps.success(body)` / `deps.success(definition)` turns legs 1-4 RED (the
36+
* echo carries no `version`, no lowered condition, and PUT's echo has no
37+
* `name`); reverting `AutomationEngine.registerFlow` to `void` turns the
38+
* routes' answer `undefined` and reds leg 2/3 the same way.
39+
*/
40+
41+
import { describe, it, expect } from 'vitest';
42+
import { AutomationEngine, InMemorySuspendedRunStore } from '@objectstack/service-automation';
43+
import { HttpDispatcher } from '@objectstack/runtime';
44+
import { CreateFlowResponseSchema, UpdateFlowResponseSchema } from '@objectstack/spec/api';
45+
import type { FlowParsed } from '@objectstack/spec/automation';
46+
import { ObjectStackClient } from './index';
47+
48+
const BASE_URL = 'http://localhost:3000';
49+
50+
/** The definition writes demand `manage_metadata` (ADR-0066 D1). */
51+
const CONTEXT = (): any => ({
52+
request: {},
53+
executionContext: { userId: 'usr_1', isSystem: false, systemPermissions: ['manage_metadata'] },
54+
});
55+
56+
/** A raw authored condition string — what the schema lowers to a CEL envelope. */
57+
const RAW_CONDITION = "record.status == 'approved'";
58+
59+
/**
60+
* A raw authored flow body, the way a real HTTP caller writes one: no
61+
* `version`, no `status`, no `runAs`, no per-edge `type`/`isDefault`, and a
62+
* bare STRING `edge.condition`. Every one of those is a delta the parsed
63+
* answer materializes — which is what makes this fixture able to tell the
64+
* canonicalized answer apart from an echo.
65+
*/
66+
const RAW_DEFINITION = {
67+
label: 'Write Door Flow',
68+
type: 'autolaunched',
69+
nodes: [
70+
{ id: 'start', type: 'start', label: 'Start' },
71+
{ id: 'end', type: 'end', label: 'End' },
72+
],
73+
edges: [{ id: 'e1', source: 'start', target: 'end', condition: RAW_CONDITION }],
74+
};
75+
76+
function producerBackedClient() {
77+
const engine = new AutomationEngine(
78+
{ info() {}, warn() {}, error() {}, debug() {}, child() { return this; } } as never,
79+
new InMemorySuspendedRunStore(),
80+
);
81+
const services: Record<string, unknown> = { automation: engine };
82+
const resolve = (name: string): unknown => services[name];
83+
const kernel: any = {
84+
getService: resolve,
85+
getServiceAsync: async (name: string) => resolve(name),
86+
context: { getService: resolve },
87+
};
88+
const dispatcher = new HttpDispatcher(kernel);
89+
90+
/** The last RAW wire body — the envelope `unwrapResponse` strips, kept so
91+
* the response schemas can be parsed against what really crossed the wire. */
92+
const wire: { last: unknown } = { last: undefined };
93+
94+
const fetchImpl = async (url: string, init: RequestInit = {}): Promise<any> => {
95+
const parsed = new URL(String(url));
96+
const method = init.method ?? 'GET';
97+
const body = init.body ? JSON.parse(String(init.body)) : undefined;
98+
const query = Object.fromEntries(parsed.searchParams);
99+
const dispatched = await dispatcher.handleAutomation(
100+
parsed.pathname.slice('/api/v1/automation'.length), method, body, CONTEXT(), query);
101+
expect(dispatched.handled, `the dispatcher must serve ${method} ${parsed.pathname}`).toBe(true);
102+
const status = dispatched.response?.status ?? 500;
103+
wire.last = dispatched.response?.body;
104+
return {
105+
ok: status >= 200 && status < 300,
106+
status,
107+
statusText: String(status),
108+
headers: new Headers(),
109+
json: async () => dispatched.response?.body,
110+
};
111+
};
112+
113+
const client = new ObjectStackClient({ baseUrl: BASE_URL, fetch: fetchImpl as any });
114+
return { client, engine, wire };
115+
}
116+
117+
describe('#12206 — POST /automation answers the canonicalized parsed flow, not the echo', () => {
118+
it('materializes schema defaults, lowers edge.condition, matches the read door, and conforms to CreateFlowResponseSchema', async () => {
119+
const { client, wire } = producerBackedClient();
120+
121+
const answered: FlowParsed = await client.automation.create('wd_flow', RAW_DEFINITION);
122+
123+
// ① NOT the echo: the caller never wrote any of these.
124+
expect(answered.name).toBe('wd_flow');
125+
expect(answered.version).toBe(1);
126+
expect(answered.status).toBe('draft');
127+
expect((answered as any).runAs).toBe('user');
128+
expect(answered.edges[0]).toMatchObject({ type: 'default', isDefault: false });
129+
// The one genuine type change the survey measured: string condition →
130+
// lowered `{dialect, source}` envelope.
131+
expect(answered.edges[0].condition).toEqual({ dialect: 'cel', source: RAW_CONDITION });
132+
133+
// ③ Inherited item ①: the published response schema parses the REAL
134+
// wire envelope — conformant, no longer aspirational.
135+
const envelope = CreateFlowResponseSchema.parse(wire.last);
136+
expect(envelope.success).toBe(true);
137+
expect(envelope.data.name).toBe('wd_flow');
138+
139+
// ② Write ≡ read: the write door answered exactly what the read door
140+
// now serves for the same resource.
141+
const read = await client.automation.get('wd_flow');
142+
expect(answered).toEqual(read);
143+
});
144+
});
145+
146+
describe('#12206 — PUT /automation/:name answers the canonicalized parsed flow, not the echo', () => {
147+
it('always carries name, matches the read door, and conforms to UpdateFlowResponseSchema', async () => {
148+
const { client, wire } = producerBackedClient();
149+
await client.automation.create('wd_flow', RAW_DEFINITION);
150+
151+
// The SDK sends `{ definition }`; the engine requires a COMPLETE
152+
// definition (inherited item ② — `UpdateFlowRequestSchema` no longer
153+
// claims a partial-update capability nothing implements).
154+
const updated = { name: 'wd_flow', ...RAW_DEFINITION, label: 'Write Door Flow v2' };
155+
const answered: FlowParsed = await client.automation.update('wd_flow', updated);
156+
157+
// ④ The old PUT echo answered `body.definition ?? body`, which could
158+
// omit `name` entirely; the parsed answer always carries it.
159+
expect(answered.name).toBe('wd_flow');
160+
expect(answered.label).toBe('Write Door Flow v2');
161+
// ① NOT the echo — same materialized defaults as the POST door.
162+
expect(answered.version).toBe(1);
163+
expect(answered.edges[0].condition).toEqual({ dialect: 'cel', source: RAW_CONDITION });
164+
165+
// ③ Inherited item ①, update half.
166+
const envelope = UpdateFlowResponseSchema.parse(wire.last);
167+
expect(envelope.success).toBe(true);
168+
expect(envelope.data.label).toBe('Write Door Flow v2');
169+
170+
// ② Write ≡ read.
171+
const read = await client.automation.get('wd_flow');
172+
expect(answered).toEqual(read);
173+
});
174+
});

0 commit comments

Comments
 (0)