From 45c2720d286222c30b42eeb9327e72c9dd7054f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 13:01:51 +0000 Subject: [PATCH] fix(runtime): declared `type: 'flow'` endpoints answer the #9378 flow-dispatch status table (#9462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Door 3 of the #9446 ruling. `executeFlow` ended with an unconditional `successAnswer(await automation.execute(...))`, so every refusal left the seam as `200 {success:true,data:{success:false,...}}` — the double envelope #3962 removed from /actions, on the surface an app publishes as its own API. It now reads `flow-dispatch-status.ts`, the one shared definition the trigger door and /actions already read: 404 / 409 FLOW_DISABLED / 422 FLOW_NO_START_NODE / 400 FLOW_FAILED. Co-Authored-By: Claude --- .../declared-endpoints-flow-status-table.md | 63 +++ content/docs/automation/flows.mdx | 15 +- .../docs/protocol/kernel/http-protocol.mdx | 2 +- packages/runtime/src/endpoint-executor.ts | 98 +++- .../src/endpoint-flow-dispatch-status.test.ts | 426 ++++++++++++++++++ 5 files changed, 595 insertions(+), 9 deletions(-) create mode 100644 .changeset/declared-endpoints-flow-status-table.md create mode 100644 packages/runtime/src/endpoint-flow-dispatch-status.test.ts diff --git a/.changeset/declared-endpoints-flow-status-table.md b/.changeset/declared-endpoints-flow-status-table.md new file mode 100644 index 0000000000..b0a9c37e58 --- /dev/null +++ b/.changeset/declared-endpoints-flow-status-table.md @@ -0,0 +1,63 @@ +--- +"@objectstack/runtime": minor +--- + +fix(runtime): a declared `type: 'flow'` endpoint answers the #9378 flow-dispatch status table, from the one shared definition (#9462) + + + +**BREAKING** for any caller that reads a declared endpoint's flow result out of +the response body instead of the HTTP status. + +`POST /api/v1/apps//` with `type: 'flow'` used to answer +`200` for every outcome, with the raw engine result in `data` — so a flow that +was disabled, had no start node, could not be found, or ran and was rejected all +reached the caller as `{"success":true,"data":{"success":false,…}}`. That is the +double envelope #3962 removed from `POST /api/v1/actions/:object/:action`, and +it was still standing on the surface an app publishes as its own public API: a +client branching on the HTTP status read every one of those failures as a +success. + +It now answers the same four rows the other two flow doors answer, read from the +one shared definition in `packages/runtime/src/flow-dispatch-status.ts` rather +than from a third private copy of the rule: + +| engine exit | reality | the endpoint answers | +|:---|:---|:---| +| flow not found | never dispatched | `404` | +| flow disabled | never dispatched | `409` `FLOW_DISABLED` | +| flow has no start node | never dispatched | `422` `FLOW_NO_START_NODE` | +| ran and was rejected | ran, rejected | `400` `FLOW_FAILED` | + +What a caller sees differently: + +- **A failed or refused flow is now a 4xx.** The body is the platform's declared + error envelope, `{"success":false,"error":{"code","message","httpStatus"}}`; + there is no inner `data.success` left to read. A caller that already branched + on the status now sees the failure it was previously told was a success; a + caller that branched on `data.success` gets the same fact from `error.code`. +- **A `400` carries the run's own artefacts** in `error.details` + (`errorMessage`, `summary`), exactly as `POST /api/v1/automation/:name/trigger` + carries them. The three never-dispatched rows carry neither, because no run + happened to describe. +- **A successful run is unchanged** — still `200` with the result in `data`. +- **An `outputMapping` declaration is no longer applied to a failure.** The + projection was already restricted to answers with a status below 400, so the + refusal rows fall outside it by the rule that was already written. This closes + a real hole: an `outputMapping` used to be applied to the `200`-wrapped failure + body and could present a refused dispatch as data. +- Both policy behaviours keyed on the same test move with it: `cacheTtl`'s + `Cache-Control` no longer rides a flow failure, and the `rateLimit` / + `authRequired` chain is untouched — it runs before execution either way. + +This is the third and last door of the #9446 ruling (maintainer, 2026-08-18, +verbatim 「同意」: the status table is a property of the flow-dispatch CONTRACT, +not of the trigger route). All three doors now read one definition, and the +suite asserts that by driving the same engine result through all three and +comparing. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 4fed46c324..62da81a546 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -1310,13 +1310,14 @@ to do it — never the message text: The first three never dispatched anything: no node executed, no record was written, and there is no run to look up. Only `400` describes a run. -**The same table answers at the action door.** Invoking a `type: 'flow'` -[action](/docs/ui/actions) through `POST /api/v1/actions/:object/:action` -dispatches the same flow through the same service call, and it classifies the -outcome the same way — the table above has one definition that both doors read, -so they cannot answer one engine outcome differently. Declared endpoints -(`type: 'flow'`) are the remaining exception: they still answer `200` for every -outcome, so read `data.success` there rather than the status. +**The same table answers at every door that dispatches a flow.** Invoking a +`type: 'flow'` [action](/docs/ui/actions) through +`POST /api/v1/actions/:object/:action`, and calling a declared `type: 'flow'` +[endpoint](/docs/protocol/kernel/http-protocol) under `/api/v1/apps/`, both +dispatch the same flow through the same service call and classify the outcome +the same way — the table above has one definition that all three doors read, so +they cannot answer one engine outcome differently. Branch on the status and +`error.code` at any of them. ``` POST /api/v1/automation/order_approval/trigger diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 3b9b1260b5..bf75732cdb 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -1216,7 +1216,7 @@ declaration to shadow a built-in route: | Endpoint declares | Answer | |:---|:---| | `type: 'object_operation'` | delegated to the same `callData` binding that serves `/api/v1/data/{object}` — byte-identical `data` | -| `type: 'flow'` | delegated to the same automation pipeline as `POST /api/v1/automation/{name}/trigger` — the same execution context builder and the same `execute` call, so the run itself is identical. **The response is not**: the trigger route classifies a refused or failed run into real status codes (404 / 409 `FLOW_DISABLED` / 422 `FLOW_NO_START_NODE` / 400 `FLOW_FAILED`), and `POST /api/v1/actions/{object}/{action}` answers that same table since #9446, while this seam still answers `200` with the result in `data` for every outcome ([#9462](https://github.com/objectstack-ai/objectstack/issues/9462)). Read `data.success` here, not the status | +| `type: 'flow'` | delegated to the same automation pipeline as `POST /api/v1/automation/{name}/trigger` — the same execution context builder, the same `execute` call, and **the same response contract**: a refused or failed run is classified into the same real status codes (404 / 409 `FLOW_DISABLED` / 422 `FLOW_NO_START_NODE` / 400 `FLOW_FAILED`), from one shared definition all three flow doors read. Branch on the status and `error.code`, never on an inner success flag | | `authRequired: true` (or omitted) + anonymous caller | `401` `UNAUTHENTICATED`, the same envelope every seam answers | | `rateLimit` armed and exhausted | `429` + `Retry-After`, never with a cache directive | | `cacheTtl: 30` on a successful GET | `Cache-Control: private, max-age=30` — `private` is a security rule, not tuning: any response can be RLS-trimmed | diff --git a/packages/runtime/src/endpoint-executor.ts b/packages/runtime/src/endpoint-executor.ts index a901677750..038ecbfa40 100644 --- a/packages/runtime/src/endpoint-executor.ts +++ b/packages/runtime/src/endpoint-executor.ts @@ -54,6 +54,12 @@ import { apiErrorResponse } from './error-envelope.js'; import { isServiceServeable } from './service-serveable.js'; import { validationFailure } from './validation-failure.js'; import { buildAutomationContext } from './domains/automation.js'; +import { + classifyFlowRefusal, + flowIsUnknown, + flowNotFoundMessage, + FLOW_NOT_FOUND_STATUS, +} from './flow-dispatch-status.js'; import type { HttpProtocolContext } from './http-dispatcher.js'; // ============================================================================ @@ -446,6 +452,41 @@ async function executeObjectOperation( * * The request body is the flow input, exactly as on `POST * /automation/:name/trigger`. + * + * ## [#9462] The outcome is the #9378 status table, from the ONE definition + * + * | engine exit | reality | this door answers | + * |------------------------|------------------|----------------------------| + * | flow not found | never dispatched | `404` | + * | flow disabled | never dispatched | `409` `FLOW_DISABLED` | + * | flow has no start node | never dispatched | `422` `FLOW_NO_START_NODE` | + * | ran and failed | ran, rejected | `400` `FLOW_FAILED` | + * + * Read from `../flow-dispatch-status.js` — the third and last door to converge + * on it (maintainer ruling, 2026-08-18, verbatim 「同意」: the table is a + * property of the flow-dispatch CONTRACT, not of the trigger route). ⛔ A + * fourth copy of the table, here or anywhere, is the defect by construction: + * three private readings of one engine result is exactly how a DISABLED flow + * came to be `409` at the trigger door, `400 FLOW_FAILED` at `/actions`, and + * `200` here, all at once. + * + * **This is a BREAKING change to what this seam answers.** Until now every + * outcome was `200` with the raw result in `data`, so a failing flow reached + * the caller as `{"success":true,"data":{"success":false,…}}` — the double + * envelope #3962 ruled out for `/actions`, on a surface an app publishes as + * its own public API. A consumer that branched on `data.success` now gets a + * 4xx whose `error.code` carries the same fact; one that branched on the HTTP + * status alone was reading failures as successes and now reads them correctly. + * + * `outputMapping` needs nothing here and deliberately gets nothing: it is + * applied by `api-endpoint-step.ts` on `answer.status < 400`, so the refusal + * rows fall outside it by the rule that was already written — which is also + * the fix for a real hole, since an `outputMapping` projection used to be + * applied to the `200`-wrapped FAILURE body and could present it as data. The + * policy chain is upstream of this function and is untouched: a refusal here + * is reached only by a request that already passed `rateLimit` / + * `authRequired`, and `Cache-Control` from `cacheTtl` rides success only, + * again on the same `status < 400` test. */ async function executeFlow( ctx: EndpointExecutionContext, @@ -479,8 +520,63 @@ async function executeFlow( }); } + // [#9462] Row 1 of the table, answered by the SAME optional `getFlow` + // registry probe the trigger door and `/actions` use — the engine's + // not-found exit carries neither a `code` nor a `status`, so this is the + // only reading of it that is not a regex over its message (PD #12). A + // service that omits `getFlow` cannot be asked and dispatches as before, + // exactly as at the other two doors. + if (await flowIsUnknown(service, plan.flow)) { + return apiErrorResponse({ + message: sanitizeMessage(flowNotFoundMessage(plan.flow), FLOW_NOT_FOUND_STATUS), + httpStatus: FLOW_NOT_FOUND_STATUS, + }); + } + const automationContext = buildAutomationContext(ctx.body, ctx.protocolContext) as AutomationContext; - return successAnswer(await automation.execute(plan.flow, automationContext)); + const result = await automation.execute(plan.flow, automationContext); + + // [#9462] Rows 2-4, read off the PRODUCER's classification through the one + // shared table. What stood here was an unconditional `successAnswer`, so + // EVERY refusal — a flow that never dispatched included — reached the + // caller as `200 {success:true,data:{success:false,…}}`: the double + // envelope #3962 removed from `/actions`, on a surface an app publishes as + // its own public API. + const refusal = classifyFlowRefusal(plan.flow, result); + if (refusal) { + // The run's own artefacts ride the 400 arm ONLY — they describe a run + // that happened, and a never-dispatched refusal has neither an author + // failure text nor a node log to point at. Byte-identical to the + // trigger door's details (`domains/automation.ts`), because #5040 §4 + // makes that route's answer this seam's contract. + const runDetails = refusal.code === 'FLOW_FAILED' + ? { + ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}), + ...(result.summary !== undefined ? { summary: result.summary } : {}), + } + : {}; + return apiErrorResponse({ + message: sanitizeMessage(refusal.message, refusal.status), + httpStatus: refusal.status, + code: refusal.code, + ...(Object.keys(runDetails).length > 0 ? { details: runDetails } : {}), + }); + } + + // An UNCLASSIFIED `success: false` keeps today's 200 — this door reads it + // the TRIGGER door's way, not `/actions`'s, and the difference is decided + // by what a declared endpoint IS. #5040 §4 (this module's opening rule) + // makes a `type: 'flow'` endpoint a stable URL plus a policy layer over + // `POST /automation/:name/trigger`: same context builder, same `execute` + // call, so the same answer, or the alias has become the second execution + // dialect the whole module exists to prevent. `/actions` refuses the + // residual under its own #3962 ruling about ITS route; adopting that here + // would PROMOTE an exit the producer never classified — the one thing the + // shared table's note says a door must not do — and would do it by + // borrowing a ruling about a different door. If the residual should speak + // HTTP everywhere, that is a change to the shared table for all three + // doors, not a fourth reading invented at this one. + return successAnswer(result); } /** diff --git a/packages/runtime/src/endpoint-flow-dispatch-status.test.ts b/packages/runtime/src/endpoint-flow-dispatch-status.test.ts new file mode 100644 index 0000000000..4c936f3add --- /dev/null +++ b/packages/runtime/src/endpoint-flow-dispatch-status.test.ts @@ -0,0 +1,426 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #9462 — a declared endpoint (`type: 'flow'`) answers the #9378 status table, + * the same one the trigger routes and `/actions` answer. Door 3 of 3. + * + * `executeFlow` (`endpoint-executor.ts`) ended with one unconditional line: + * + * ```ts + * return successAnswer(await automation.execute(plan.flow, automationContext)); + * ``` + * + * so EVERY outcome — including the three that never dispatched a node — left + * this seam as `200 {"success":true,"data":{"success":false,…}}`: the double + * envelope #3962 ruled out for `/actions`, on a surface an app publishes as + * its own public API. A caller branching on the HTTP status read a refused + * flow as a successful one. + * + * Maintainer ruling, 2026-08-18, verbatim 「同意」: the table is a property of + * the flow-dispatch CONTRACT rather than of the trigger route, converged in + * stages — this is the last stage, and it converges by CALLING + * `flow-dispatch-status.ts` rather than by writing a third copy of the table. + * + * ## What this file pins, and why in three parts + * + * A suite that only asserted the new codes would stay green under a regression + * that collapsed every exit back to one answer — each per-row assertion would + * still be satisfiable by that one answer whenever it happened to be the row's + * own. So: + * + * 1. each row answers its own status AND its own code; + * 2. the rows are asserted as a SET, so a collapse reddens here whatever the + * surviving answer is; and + * 3. the same `AutomationResult` is driven through ALL THREE doors and must + * come back with the same status and the same code. That is the ruling's + * actual content: per-door assertions are satisfiable by three copies of a + * rule, and three copies drifting apart is the defect being closed. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ApiEndpointSchema, ApiErrorSchema, BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api'; +import type { AutomationResult } from '@objectstack/spec/contracts'; +import type { ApiEndpointMatch } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; + +import { + buildEndpointExecutionContext, + executeEndpointTarget, + type EndpointExecutionAnswer, +} from './endpoint-executor.js'; +import { HttpDispatcher } from './http-dispatcher.js'; + +const FLOW = 'purge_inquiries'; + +/** The declared endpoint under test — the ADR-0121 D1 shape, defaults materialized. */ +const FLOW_ENDPOINT = () => + ApiEndpointSchema.parse({ + name: 'showcase_purge', + path: '/api/v1/apps/showcase/purge', + method: 'POST', + type: 'flow', + target: FLOW, + }); + +const EC: ExecutionContext = { + userId: 'user-1', + positions: ['sales_rep'], + permissions: ['task_edit'], + tenantId: 'tenant-9', +} as ExecutionContext; + +/** + * ONE automation service, shaped like the real engine: `getFlow` reads the same + * flow map `execute` reads (`engine.ts`), so it resolves `null` for exactly the + * names `execute` would answer "not found" for, and `execute` returns an + * `AutomationResult` rather than throwing. + * + * The SAME object is handed to all three doors below, which is what makes the + * parity block a comparison rather than three independent fixtures. + */ +function automationServiceWith(opts: { + result?: AutomationResult; + flows?: string[]; + omitGetFlow?: boolean; +} = {}) { + const names = opts.flows ?? [FLOW]; + const held = new Map(names.map((n) => [n, { name: n }])); + const execute = vi.fn(async (): Promise => opts.result ?? { success: true, output: {} }); + const getFlow = vi.fn(async (name: string) => held.get(name) ?? null); + const service: Record = opts.omitGetFlow ? { execute } : { execute, getFlow }; + return { service, execute, getFlow }; +} + +/** Drive the declared endpoint — door 3. */ +async function viaEndpoint(service: unknown, body: unknown = {}): Promise { + const match: ApiEndpointMatch = { endpoint: FLOW_ENDPOINT(), params: {} }; + const ctx = buildEndpointExecutionContext({ + request: { + method: 'POST', + path: '/api/v1/apps/showcase/purge', + query: {}, + headers: {}, + body, + }, + match, + executionContext: EC, + environmentId: 'env-7', + }); + return executeEndpointTarget(ctx, { + callData: vi.fn().mockResolvedValue({ ok: true }), + automationService: service, + }); +} + +/** Every error answer must be the declared envelope, whatever produced it. */ +function expectConformantError(answer: EndpointExecutionAnswer) { + const body: any = answer.body; + expect(BaseResponseSchema.safeParse(body).success).toBe(true); + expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]); + expect(body.success).toBe(false); + expect(ApiErrorSchema.safeParse(body.error).success).toBe(true); + expect(body.error.httpStatus).toBe(answer.status); + return body.error; +} + +/** The engine's ran-and-failed exit: `status: 'failed'` is the producer's verdict. */ +const RAN_AND_FAILED: AutomationResult = { + success: false, + status: 'failed', + error: "Node 'purge_batch' failed: Amount must be greater than zero", + durationMs: 45, +}; + +/** The engine's two never-dispatched exits, each stamped with its own code (#9415). */ +const DISABLED: AutomationResult = { + success: false, code: 'FLOW_DISABLED', error: `Flow '${FLOW}' is disabled`, +}; +const NO_START_NODE: AutomationResult = { + success: false, code: 'FLOW_NO_START_NODE', error: 'Flow has no start node', +}; + +describe('#9462 — a declared `type: flow` endpoint answers the four-row table', () => { + it('row 1: a flow the service does not hold is 404, and is never dispatched', async () => { + const { service, execute, getFlow } = automationServiceWith({ flows: [] }); + + const answer = await viaEndpoint(service); + + expect(answer.status).toBe(404); + const error = expectConformantError(answer); + // Named, so the caller knows WHICH declared target failed to resolve. + expect(error.message).toContain(FLOW); + // The same shared probe the other two doors use, asked with the + // declaration's own target; the engine is never asked to run a name + // nothing holds. + expect(getFlow).toHaveBeenCalledWith(FLOW); + expect(execute).not.toHaveBeenCalled(); + // ⛔ Never the old answer: a 200 for a flow that does not exist. + expect(answer.status).not.toBe(200); + }); + + it('row 2: a disabled flow is 409 FLOW_DISABLED, not a 200 carrying a false success flag', async () => { + const { service } = automationServiceWith({ result: DISABLED }); + + const answer = await viaEndpoint(service); + + expect(answer.status).toBe(409); + const error = expectConformantError(answer); + expect(error.code).toBe('FLOW_DISABLED'); + // The engine's own words survive — an operator needs to know WHICH flow + // was refused and why enabling it will help. + expect(error.message).toBe(DISABLED.error); + // The double envelope is GONE: there is no inner `data.success` left + // for a status-blind caller to have to read. + expect((answer.body as any).data).toBeUndefined(); + expect((answer.body as any).success).toBe(false); + }); + + it('row 3: a flow with no start node is 422 FLOW_NO_START_NODE', async () => { + const { service } = automationServiceWith({ result: NO_START_NODE }); + + const answer = await viaEndpoint(service); + + expect(answer.status).toBe(422); + const error = expectConformantError(answer); + expect(error.code).toBe('FLOW_NO_START_NODE'); + expect(error.message).toBe(NO_START_NODE.error); + expect((answer.body as any).data).toBeUndefined(); + }); + + it('row 4: a run that dispatched and was rejected is 400 FLOW_FAILED, with the run’s own artefacts', async () => { + const { service } = automationServiceWith({ + result: { + ...RAN_AND_FAILED, + errorMessage: 'Could not purge: the batch is locked', + summary: { + selected: 3, acted: 0, skipped: 3, unmeasured: 0, + nodes: [{ nodeId: 'purge_batch', nodeType: 'update_record', status: 'failure', runs: 1, failures: 1 }], + } as AutomationResult['summary'], + }, + }); + + const answer = await viaEndpoint(service); + + expect(answer.status).toBe(400); + const error = expectConformantError(answer); + expect(error.code).toBe('FLOW_FAILED'); + // The 400 arm carries the run's artefacts, byte-identical to the + // trigger door — the flow AUTHOR's own failure text and the node + // summary that says WHICH node failed. The ADR-0112 envelope has no + // `data`, so `details` is the only place they can ride. + expect((error.details as any).errorMessage).toBe('Could not purge: the batch is locked'); + expect((error.details as any).summary.nodes[0].nodeId).toBe('purge_batch'); + }); + + it('the four rows are DISTINGUISHABLE — the half a per-row assertion cannot see', async () => { + // A regression that collapsed the table back to one answer would leave + // every per-row assertion above satisfiable by that one answer if it + // happened to be the row's own. Asserted as a SET, it cannot. + const answers = await Promise.all( + [ + automationServiceWith({ flows: [] }), + automationServiceWith({ result: DISABLED }), + automationServiceWith({ result: NO_START_NODE }), + automationServiceWith({ result: RAN_AND_FAILED }), + ].map(async ({ service }) => { + const answer = await viaEndpoint(service); + return { status: answer.status, code: (answer.body as any).error?.code }; + }), + ); + + expect(answers.map((a) => a.status)).toEqual([404, 409, 422, 400]); + expect(new Set(answers.map((a) => a.status)).size).toBe(4); + // Codes too: two rows sharing a status would still be two different + // facts, and an SDK branches on `code`. + expect(new Set(answers.map((a) => a.code)).size).toBe(4); + // Exactly ONE row may claim the flow ran. + expect(answers.filter((a) => a.code === 'FLOW_FAILED')).toHaveLength(1); + // And none of them is the old blanket 200. + expect(answers.filter((a) => a.status === 200)).toHaveLength(0); + }); + + it('classifies off the producer’s verdict, never off `summary` / `durationMs`', async () => { + // A refused dispatch carrying the incidental fields of a failed run is + // still a refused dispatch. A door that sniffed shape instead of + // reading the classification would answer 400 here. + const { service } = automationServiceWith({ + result: { + ...DISABLED, + durationMs: 45, + summary: { + selected: 0, acted: 0, skipped: 0, unmeasured: 0, + nodes: [{ nodeId: 'n', nodeType: 'create_record', status: 'failure', runs: 1, failures: 1 }], + } as AutomationResult['summary'], + }, + }); + + const answer = await viaEndpoint(service); + + expect(answer.status).toBe(409); + expect((answer.body as any).error.code).toBe('FLOW_DISABLED'); + }); + + it('a successful run is UNCHANGED — 200 with the raw result in `data`', async () => { + const { service } = automationServiceWith({ result: { success: true, output: { purged: 12 } } }); + + const answer = await viaEndpoint(service); + + expect(answer.status).toBe(200); + expect(answer.body).toEqual({ success: true, data: { success: true, output: { purged: 12 } }, meta: undefined }); + }); + + it('a service without `getFlow` keeps the probe optional and still answers the result rows', async () => { + // `getFlow?` is optional on `IAutomationService`. One that omits it + // cannot be asked whether a flow exists, so this dispatches rather than + // inventing a 404 — exactly as the other two doors behave. + const { service, execute } = automationServiceWith({ omitGetFlow: true, result: DISABLED }); + + const answer = await viaEndpoint(service); + + expect(execute).toHaveBeenCalledTimes(1); + expect(answer.status).toBe(409); + expect((answer.body as any).error.code).toBe('FLOW_DISABLED'); + }); + + it('an UNCLASSIFIED refusal keeps today’s 200 — this door reads it the TRIGGER door’s way', async () => { + // The residual the shared table deliberately does not own, and the one + // place the three doors do not all agree. #5040 §4 makes a declared + // `flow` endpoint a stable URL plus a policy layer over `POST + // /automation/:name/trigger` — same context builder, same `execute` + // call — so it answers as that route answers. `/actions` refuses the + // residual under its own #3962 ruling about ITS route; adopting that + // here would PROMOTE an exit the producer never classified, which is + // the one thing the shared table's note says a door must not do. + const { service } = automationServiceWith({ result: { success: false, error: 'something odd' } }); + + const answer = await viaEndpoint(service); + + expect(answer.status).toBe(200); + expect((answer.body as any).data).toEqual({ success: false, error: 'something odd' }); + }); +}); + +// --------------------------------------------------------------------------- +// Cross-door parity — the ruling's actual content +// --------------------------------------------------------------------------- + +/** + * A dispatcher serving doors 1 and 2 from the SAME automation service object + * the endpoint door is given, so a divergence can only come from the doors' + * own readings and never from two different fixtures. + */ +function dispatcherOver(service: unknown) { + const flowAction = { + name: 'purge', + label: 'Purge', + objectName: 'showcase_inquiry', + type: 'flow', + target: FLOW, + }; + const objectDef = { name: 'showcase_inquiry', actions: [flowAction] }; + const ql: any = { + executeAction: vi.fn(async () => ({ ran: 'script' })), + getSchema: (name: string) => (name === objectDef.name ? objectDef : undefined), + registry: { + getObject: (name: string) => (name === objectDef.name ? objectDef : undefined), + getItem: () => undefined, + }, + find: vi.fn(async () => []), + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + }; + const metadata: any = { + load: vi.fn(async () => null), + loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })), + listObjects: vi.fn(async () => [objectDef]), + getObject: vi.fn(async () => objectDef), + }; + const resolve = (n: string) => + n === 'objectql' || n === 'data' ? ql + : n === 'metadata' ? metadata + : n === 'automation' ? service + : null; + const kernel: any = { + getService: resolve, + getServiceAsync: async (n: string) => resolve(n), + context: { getService: resolve }, + }; + return new HttpDispatcher(kernel); +} + +const CTX: any = { + request: {}, + environmentId: 'platform', + executionContext: { userId: 'user-1', systemPermissions: [] }, +}; + +/** Trigger the same flow directly — door 1, the route a declared endpoint aliases. */ +const viaTrigger = (d: HttpDispatcher) => d.handleAutomation(`/${FLOW}/trigger`, 'POST', {}, CTX); + +/** Invoke the same flow as an ACTION — door 2. */ +const viaAction = (d: HttpDispatcher) => d.handleActions('/showcase_inquiry/purge', 'POST', {}, CTX); + +describe('#9462 — all three doors read ONE table, so they cannot drift', () => { + for (const [label, result, status, code] of [ + ['a disabled flow', DISABLED, 409, 'FLOW_DISABLED'], + ['a flow with no start node', NO_START_NODE, 422, 'FLOW_NO_START_NODE'], + ['a run that ran and failed', RAN_AND_FAILED, 400, 'FLOW_FAILED'], + ] as Array<[string, AutomationResult, number, string]>) { + it(`${label}: the declared endpoint, /actions and /automation/:name/trigger all answer ${String(status)}`, async () => { + const { service } = automationServiceWith({ result }); + const dispatcher = dispatcherOver(service); + + const endpoint = await viaEndpoint(service); + const trigger: any = await viaTrigger(dispatcher); + const action: any = await viaAction(dispatcher); + + expect(endpoint.status).toBe(status); + expect(trigger.response.status).toBe(status); + expect(action.response.status).toBe(status); + expect((endpoint.body as any).error.code).toBe(code); + expect(trigger.response.body.error.code).toBe(code); + expect(action.response.body.error.code).toBe(code); + }); + } + + it('an unknown flow is 404 at all three doors, and none of them dispatches it', async () => { + const { service, execute } = automationServiceWith({ flows: [] }); + const dispatcher = dispatcherOver(service); + + const endpoint = await viaEndpoint(service); + const trigger: any = await viaTrigger(dispatcher); + const action: any = await viaAction(dispatcher); + + expect(endpoint.status).toBe(404); + expect(trigger.response.status).toBe(404); + expect(action.response.status).toBe(404); + expect(execute).not.toHaveBeenCalled(); + }); + + it('a successful run is 200 at all three doors', async () => { + const { service } = automationServiceWith({ result: { success: true, output: { purged: 1 } } }); + const dispatcher = dispatcherOver(service); + + const endpoint = await viaEndpoint(service); + const trigger: any = await viaTrigger(dispatcher); + const action: any = await viaAction(dispatcher); + + expect(endpoint.status).toBe(200); + expect(trigger.response.status).toBe(200); + expect(action.response.status).toBe(200); + }); + + it('the declared endpoint matches the TRIGGER door exactly on the unclassified residual', async () => { + // Stated as a pin rather than left implicit: the residual is the one + // outcome on which the three doors are not identical, and #5040 §4 is + // what decides which pair must agree. `/actions` refusing it is its own + // #3962 ruling and is asserted in `actions-flow-dispatch-status.test.ts`. + const { service } = automationServiceWith({ result: { success: false, error: 'something odd' } }); + const dispatcher = dispatcherOver(service); + + const endpoint = await viaEndpoint(service); + const trigger: any = await viaTrigger(dispatcher); + + expect(endpoint.status).toBe(200); + expect(trigger.response.status).toBe(200); + }); +});