Skip to content
28 changes: 28 additions & 0 deletions .changeset/25486-unknown-resource-404.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/runtime': patch
'@objectstack/service-datasource': patch
---

fix(rest): an unknown resource answers `404 RESOURCE_NOT_FOUND` at the package draft and commit-trail doors, the flow runs list and delete, and the datasource by-name write doors, as their read siblings already did

Clause-②: no (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What changes is the HTTP answer eight REST doors give for a resource name that does not exist. -->

**BREAKING** (a served-set narrowing) for a caller that sends one of these doors a package id, flow name or datasource name that does not exist. Five of them used to answer such a request `200` with an empty result. All eight now refuse it.

Eight doors answered a request about a resource that does not exist as if it were an empty or a bad request, while their sibling doors answered `404` for the same name. An SDK or AI caller could not tell "no such thing" from "nothing yet". Each now answers `404 RESOURCE_NOT_FOUND`, with the status, code and message its sibling gives for the same name. The answer before and after, per door:

- `POST /api/v1/packages/:id/publish-drafts`, for a package the registry does not hold: `200 { success: false, outcome: 'nothing_to_publish' }` → `404 RESOURCE_NOT_FOUND "Package '<id>' not found"`. That is the answer of `GET /api/v1/packages/:id`.
- `POST /api/v1/packages/:id/discard-drafts`: `200 { success: true, discardedCount: 0 }` → the same `404`.
- `GET /api/v1/packages/:id/commits`: `200 { commits: [] }` → the same `404`.
- `GET /api/v1/automation/:name/runs`, for a flow the engine does not hold: `200 { runs: [], hasMore: false }` → `404 RESOURCE_NOT_FOUND "Flow '<name>' not found"`. That is the answer of `POST /api/v1/automation/:name/trigger`.
- `DELETE /api/v1/automation/:name`, for a flow that neither the engine nor the metadata store holds: `200 { name, deleted: true }` → the same `404`.
- `PATCH /api/v1/datasources/:name`, for a datasource that does not exist: `400 DATASOURCE_ADMIN_ERROR "Datasource '<name>' not found."` → `404 RESOURCE_NOT_FOUND 'Datasource "<name>" does not exist.'`. That is the answer of `GET /api/v1/datasources/:name`, word for word.
- `DELETE /api/v1/datasources/:name`: the same `400` → the same `404`.
- `POST /api/v1/datasources/:name/migrate-credential`: the same `400` → the same `404`.

Unchanged: a resource that exists keeps its answer. An empty page for a flow that has not run, `nothing_to_publish` for a package with nothing pending, and `400 DATASOURCE_ADMIN_ERROR` for a code-defined datasource all stay as they were. Drafts or commits the store holds for an id the registry no longer lists are still published, discarded or listed. A stored flow the engine does not hold is still deleted. A failed ADR-0045 visibility flip still answers `200` with its `unhideError`.

**What changes for you.** `ObjectStackClient` already throws on any non-2xx answer. `packages.publishDrafts`, `packages.discardDrafts`, `packages.listCommits`, `automation.listRuns` and `automation.delete` now reject with the not-found message for an unknown id, instead of resolving an empty result. Treat that rejection as "no such resource". An embedder calling `DatasourceAdminService.updateDatasource`, `removeDatasource` or `migrateCredential` directly now gets an error carrying `code: 'RESOURCE_NOT_FOUND'` and `status: 404`. Its message is now `Datasource "<name>" does not exist.`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `DELETE /automation/:name` for a flow that does not exist is `404
* RESOURCE_NOT_FOUND`, the answer `POST /automation/:name/trigger` gives the
* same name.
*
* It answered `200 { name, deleted: true }`: the engine's `unregisterFlow` is a
* no-op for a name it does not hold, and the metadata protocol's
* `deleteMetaItem` answers a name with no row `{ success: true, reset: false,
* "… nothing to delete." }` — so the door reported a removal of something that
* was never there. The door now asks the trigger door's `flowIsUnknown` probe,
* and answers 404 only when the store removed nothing either: a flow whose row
* is stored but which the engine does not hold (one that failed to register)
* is still deletable here, as before.
*
* The protocol double answers in the real `deleteMetaItem`'s two receipt shapes
* (`packages/metadata-protocol/src/protocol.ts`): `reset: false` when no row is
* found, `reset: true` when a row was deleted.
*/

import { describe, it, expect, vi } from 'vitest';

import { HttpDispatcher } from '../http-dispatcher.js';

const AUTHOR = () => ({
request: {},
executionContext: { userId: 'u_admin', systemPermissions: ['manage_metadata'] },
}) as any;

const NOTHING_TO_DELETE = (name: string) => ({ success: true, reset: false, message: `No flow '${name}' found — nothing to delete.` });
const ROW_DELETED = { success: true, reset: true };

function makeDispatcher(opts: { engine?: string[]; stored?: string[] } = {}) {
const flows = new Map((opts.engine ?? ['welcome_flow']).map((n) => [n, { name: n, nodes: [], edges: [] }]));
const stored = new Set(opts.stored ?? opts.engine ?? ['welcome_flow']);
const getFlow = vi.fn(async (name: string) => flows.get(name) ?? null);
const unregisterFlow = vi.fn((name: string) => { flows.delete(name); });
const registerFlow = vi.fn((name: string, def: any) => { flows.set(name, def); return def; });
const execute = vi.fn(async () => ({ success: true, output: {} }));
const deleteMetaItem = vi.fn(async ({ name }: { name: string }) => {
if (!stored.has(name)) return NOTHING_TO_DELETE(name);
stored.delete(name);
return ROW_DELETED;
});
const services: Record<string, unknown> = {
automation: { handlerReady: true, getFlow, unregisterFlow, registerFlow, execute },
protocol: { deleteMetaItem, getMetaItems: async () => ({ items: [] }) },
};
const resolve = (name: string) => services[name];
const kernel: any = { getService: resolve, getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve } };
return { dispatcher: new HttpDispatcher(kernel), getFlow, unregisterFlow, deleteMetaItem, stored };
}

describe('DELETE /automation/:name — an unknown flow is 404, never 200 deleted', () => {
it('answers 404 RESOURCE_NOT_FOUND naming the flow', async () => {
const { dispatcher, getFlow } = makeDispatcher();

const result = await dispatcher.handleAutomation('/no_such_flow_xyz', 'DELETE', undefined, AUTHOR(), undefined);

expect(result.response?.status).toBe(404);
expect(result.response?.body?.success).toBe(false);
expect(result.response?.body?.error?.code).toBe('RESOURCE_NOT_FOUND');
expect(result.response?.body?.error?.message).toContain('no_such_flow_xyz');
expect(getFlow).toHaveBeenCalledWith('no_such_flow_xyz');
});

it('gives the trigger door\'s answer for the same name — status, code and message', async () => {
const { dispatcher } = makeDispatcher();

const removed = await dispatcher.handleAutomation('/no_such_flow_xyz', 'DELETE', undefined, AUTHOR(), undefined);
const trigger = await dispatcher.handleAutomation('/no_such_flow_xyz/trigger', 'POST', {}, AUTHOR());

expect(trigger.response?.status).toBe(404);
const answer = (r: any) => ({ status: r.response?.status, code: r.response?.body?.error?.code, message: r.response?.body?.error?.message });
expect(answer(removed)).toEqual(answer(trigger));
});

it('control: a flow the engine holds is still removed, 200 deleted', async () => {
const { dispatcher, unregisterFlow, stored } = makeDispatcher();

const result = await dispatcher.handleAutomation('/welcome_flow', 'DELETE', undefined, AUTHOR(), undefined);

expect(result.response?.status).toBe(200);
expect(result.response?.body?.data).toMatchObject({ name: 'welcome_flow', deleted: true });
expect(unregisterFlow).toHaveBeenCalledWith('welcome_flow');
expect(stored.has('welcome_flow')).toBe(false);
});

it('control: a stored flow the engine does not hold is still deleted from the store, 200 deleted', async () => {
const { dispatcher, deleteMetaItem, stored } = makeDispatcher({ engine: [], stored: ['broken_flow'] });

const result = await dispatcher.handleAutomation('/broken_flow', 'DELETE', undefined, AUTHOR(), undefined);

expect(result.response?.status).toBe(200);
expect(deleteMetaItem).toHaveBeenCalledWith({ type: 'flow', name: 'broken_flow' });
expect(stored.has('broken_flow')).toBe(false);
});
});
100 changes: 100 additions & 0 deletions packages/runtime/src/domains/automation-runs-unknown-flow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `GET /automation/:name/runs` for a flow that does not exist is `404
* RESOURCE_NOT_FOUND`, the answer `POST /automation/:name/trigger` gives the
* same name.
*
* It answered `200 { runs: [], hasMore: false }` — the body a flow that exists
* and has never run gets, so an SDK or AI caller could not tell "no such flow"
* from "nothing yet". The door now asks the trigger door's own registry probe
* (`flowIsUnknown`, `../flow-dispatch-status.ts`) before listing, and the
* parity case below reads both doors side by side, so a future divergence
* between them fails here.
*/

import { describe, it, expect, vi } from 'vitest';

import { HttpDispatcher } from '../http-dispatcher.js';

const CTX = { request: {}, executionContext: { userId: 'user_1' } } as any;

/**
* An automation slot holding exactly the flows it is given: `getFlow` resolves
* `null` for an unknown name, as the real engine's does, and `listRunsPage`
* answers an empty page for ANY name — the engine's run store does, which is
* how the unknown name used to come back as a confident `[]`.
*/
function makeDispatcher(flowNames: string[] = ['welcome_flow']) {
const flows = new Map(flowNames.map((n) => [n, { name: n }]));
const getFlow = vi.fn(async (name: string) => flows.get(name) ?? null);
const listRunsPage = vi.fn(async (_flowName: string, _options?: Record<string, unknown>) => ({
runs: [] as unknown[],
hasMore: false,
}));
const execute = vi.fn(async () => ({ success: true, output: {} }));
const services: Record<string, unknown> = { automation: { getFlow, listRunsPage, execute, handlerReady: true } };
const resolve = (name: string) => services[name];
const kernel: any = {
getService: resolve,
getServiceAsync: async (name: string) => resolve(name),
context: { getService: resolve },
};
return { dispatcher: new HttpDispatcher(kernel), getFlow, listRunsPage, execute };
}

describe('GET /automation/:name/runs — an unknown flow is 404, never an empty page', () => {
it('answers 404 RESOURCE_NOT_FOUND naming the flow, and never asks the run store', async () => {
const { dispatcher, getFlow, listRunsPage } = makeDispatcher();

const result = await dispatcher.handleAutomation('/no_such_flow_xyz/runs', 'GET', undefined, CTX, {});

expect(result.handled).toBe(true);
expect(result.response?.status).toBe(404);
expect(result.response?.body?.success).toBe(false);
expect(result.response?.body?.error?.code).toBe('RESOURCE_NOT_FOUND');
expect(result.response?.body?.error?.message).toContain('no_such_flow_xyz');
expect(getFlow).toHaveBeenCalledWith('no_such_flow_xyz');
expect(listRunsPage).not.toHaveBeenCalled();
});

it('gives the trigger door\'s answer for the same name — status, code and message', async () => {
const { dispatcher, execute } = makeDispatcher();

const runs = await dispatcher.handleAutomation('/no_such_flow_xyz/runs', 'GET', undefined, CTX, {});
const trigger = await dispatcher.handleAutomation('/no_such_flow_xyz/trigger', 'POST', {}, CTX);

expect(trigger.response?.status).toBe(404);
expect(execute).not.toHaveBeenCalled();
expect({
status: runs.response?.status,
code: runs.response?.body?.error?.code,
message: runs.response?.body?.error?.message,
}).toEqual({
status: trigger.response?.status,
code: trigger.response?.body?.error?.code,
message: trigger.response?.body?.error?.message,
});
});

it('asks existence before the query: an unknown flow with a bad ?limit is still 404', async () => {
const { dispatcher, listRunsPage } = makeDispatcher();

const result = await dispatcher.handleAutomation('/no_such_flow_xyz/runs', 'GET', undefined, CTX, { limit: 'abc' });

expect(result.response?.status).toBe(404);
expect(result.response?.body?.error?.code).toBe('RESOURCE_NOT_FOUND');
expect(listRunsPage).not.toHaveBeenCalled();
});

it('control: a flow that exists and has never run is still 200 with an empty page', async () => {
const { dispatcher, listRunsPage } = makeDispatcher();

const result = await dispatcher.handleAutomation('/welcome_flow/runs', 'GET', undefined, CTX, { limit: '5' });

expect(result.response?.status).toBe(200);
const data = result.response?.body?.data ?? result.response?.body;
expect(data).toMatchObject({ runs: [], hasMore: false });
expect(listRunsPage).toHaveBeenCalledWith('welcome_flow', expect.objectContaining({ limit: 5 }));
});
});
37 changes: 34 additions & 3 deletions packages/runtime/src/domains/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1862,7 +1862,8 @@ type FlowRemovalService =
* nothing.
* - **A name with no row is not a failure** — `deleteMetaItem` answers it as
* "nothing to delete" (a flow registered before its door saved, or in a
* composition with no store).
* composition with no store). A name with no row that the engine does not
* hold either is no flow at all, and answers `404` as the trigger door does.
* - **A delete that fails puts the definition back** in the engine and
* relays the store's own failure, so no answer reports a removal that will
* not survive. A composition with no metadata store keeps the engine-only
Expand All @@ -1886,16 +1887,27 @@ async function unregisterAndDeleteFlow(
const store: (Partial<Pick<ObjectStackProtocolImplementation, 'deleteMetaItem'>>
& Partial<Pick<MetadataProtocol, 'getMetaItems'>>) | undefined =
await deps.resolveServiceOrLoud(context, 'protocol');
// The trigger door's own probe: the engine holds no such flow.
const unknownToEngine = await flowIsUnknown(automationService, name);
const held = typeof automationService.getFlow === 'function' ? await automationService.getFlow(name) : null;
automationService.unregisterFlow(name);
let rowDeleted = false;
if (typeof store?.deleteMetaItem === 'function') {
try {
await store.deleteMetaItem({ type: FLOW_METADATA_TYPE, name });
const receipt = await store.deleteMetaItem({ type: FLOW_METADATA_TYPE, name });
rowDeleted = (receipt as { reset?: unknown } | undefined)?.reset === true;
} catch (e) {
if (held) automationService.registerFlow?.(name, held);
return { handled: true, response: deps.errorFromThrown(await flowWriteRefusal(store, e, name, 'delete')) };
}
}
// Neither the engine nor the store held it: `404`, the trigger door's
// answer, never `200 {deleted: true}` for a removal of nothing. A stored
// row the engine does not hold (a flow that failed to register) is still
// removed above and answered as a deletion.
if (unknownToEngine && !rowDeleted) {
return { handled: true, response: deps.error(flowNotFoundMessage(name), FLOW_NOT_FOUND_STATUS) };
}
return undefined;
}

Expand Down Expand Up @@ -2416,6 +2428,8 @@ export async function classifyResumeResult(
* ⚑ a name two packages hold → 409
* `RESOURCE_CONFLICT` naming the metadata
* door's `DELETE /meta/flow/:name?package=`
* ⚑ a name neither the engine nor the store
* holds → 404 `RESOURCE_NOT_FOUND`
* POST /:name/trigger → execute (legacy: trigger/:name also supported;
* unknown name → 404, disabled → 409 `FLOW_DISABLED`,
* no start node → 422 `FLOW_NO_START_NODE`, node config
Expand Down Expand Up @@ -2460,7 +2474,9 @@ export async function classifyResumeResult(
* ignored rather than validated). `hasMore` is
* computed from the engine's own truncation
* report, never a constant. A service without
* `listRunsPage` → 501, ⛔ never a 200
* `listRunsPage` → 501, ⛔ never a 200. An
* unknown flow → 404 `RESOURCE_NOT_FOUND`,
* the trigger door's answer, ⛔ never `[]`
* ⚑ run-state read — `sys_automation_run` grant (#7900)
* GET /:name/runs/:runId → getRun
* ⚑ run-state read — `sys_automation_run` grant (#7900)
Expand Down Expand Up @@ -3547,6 +3563,21 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
// GET /:name/runs → listRunsPage
if (parts[1] === 'runs' && !parts[2] && m === 'GET') {
if (typeof automationService.listRunsPage === 'function') {
// An unknown flow is `404`, never `200 { runs: [] }`: an empty
// page is the answer for a flow that exists and has not run, and
// a caller cannot tell the two apart from the body. The SAME
// registry probe and message the trigger doors answer through
// (`../flow-dispatch-status.js`), so this door and
// `POST /:name/trigger` cannot disagree about which flows exist.
// Asked first, as the trigger door asks it: a query-parameter
// refusal about a flow that is not there would be the less
// useful of the two true answers.
if (await flowIsUnknown(automationService, name)) {
return {
handled: true,
response: deps.error(flowNotFoundMessage(name), FLOW_NOT_FOUND_STATUS),
};
}
// [#7300] Both options are CHECKED at the point they are read,
// in the shared query-parameter refusal this route now consumes
// with `/notifications` (#6928 / PR #7299 — the same defect, one
Expand Down
Loading
Loading