Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/automation-write-doors-parsed.md
Original file line number Diff line number Diff line change
@@ -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<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).

**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.
16 changes: 8 additions & 8 deletions content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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<string, any>; … }[]` | 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<string, any>; … }[]` | | 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. |
Expand All @@ -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`

Expand Down
4 changes: 1 addition & 3 deletions packages/client/exported-any-returns.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@
"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.",
"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.",
"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.",
"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.",
"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.",
"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."
"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."
}
}
174 changes: 174 additions & 0 deletions packages/client/src/automation-write-door-parsed-answer.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = { 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<any> => {
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);
});
});
Loading
Loading