From f8b88efbcf2362bf584510f24fd040fd5f98224c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:31:52 +0000 Subject: [PATCH 1/6] fix(rest): answer 404 RESOURCE_NOT_FOUND for an unknown resource at the draft, runs and datasource by-name doors Claude-Session: https://claude.ai/code/session_01X15AahTqrmeh8gAVdpKMCK Co-authored-by: Claude --- packages/runtime/src/domains/automation.ts | 19 +++++++- packages/runtime/src/domains/packages.ts | 43 +++++++++++++++++++ .../service-datasource/src/admin-routes.ts | 16 ++++++- .../src/datasource-admin-service.ts | 24 +++++++++-- .../src/datasource-route-ledger.ts | 3 +- 5 files changed, 99 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 63a9318bb64..ab7f3544156 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -2460,7 +2460,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) @@ -3547,6 +3549,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 diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 5234f93e94c..46b627e3b0f 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -504,6 +504,30 @@ async function refuseWalledCatalogPackageWrite( return { handled: true, response: deps.error(`${action} is refused. ${verdict.message}`, 403) }; } +/** + * Whether `registry` can be asked about `id` and answers that it holds no such + * package — the same `getPackage` lookup `GET /packages/:id`, enable and + * disable answer their `404` from, so the draft doors cannot disagree with + * them about which packages exist. An authored package is installed into this + * registry too (`POST /packages`, and `service-package` at boot), so a Studio + * app is never "unknown" here. + * + * Read by `publish-drafts` and `discard-drafts` only AFTER the protocol has + * answered, and only when that answer is empty: drafts the protocol does hold + * for an id the registry lacks are still published or discarded exactly as + * before. What changes is the one answer that lied — `200` "nothing to + * publish" / "nothing discarded" for a package that is not there, which an SDK + * or AI caller cannot tell apart from a real, empty package. + * + * A registry without `getPackage` cannot be asked, so this answers `false` + * ("no evidence of absence") rather than invent a 404. + */ +function packageIsUnknown(registry: unknown, id: string): boolean { + const reg = registry as { getPackage?: (id: string) => unknown } | null | undefined; + if (typeof reg?.getPackage !== 'function') return false; + return !reg.getPackage(id); +} + /** * [ADR-0131 D6] The customisation remedy both read-only refusals below name * ({@link requireWritablePackage}, {@link requireDuplicableSource}), in one @@ -1902,6 +1926,16 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // something did not, which is the honest report. It must // stay ABOVE the announce block, which reads this field. if (flipped.length > 0) (result as any).unhiddenApps = flipped; + // A request that touched nothing about a package this + // registry does not hold is `404`, never `200 + // nothing_to_publish` — see `packageIsUnknown`. + if ( + (result as any)?.outcome === 'nothing_to_publish' + && flipped.length === 0 + && packageIsUnknown(registry, id) + ) { + return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; + } // A publish promoted drafts to active (or published an additive // app) at RUNTIME — but boot-cached consumers still hold the // pre-publish view. The load-bearing one is the automation @@ -2000,6 +2034,15 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin packageId: id, ...(body?.actor ? { actor: body.actor } : {}), }); + // The twin of `publish-drafts`' 404: an empty discard is the + // answer for a package that exists — see `packageIsUnknown`. + if ( + (result as any)?.discardedCount === 0 + && (result as any)?.failedCount === 0 + && packageIsUnknown(registry, id) + ) { + return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; + } return { handled: true, response: deps.success(result) }; } catch (e: any) { return { handled: true, response: deps.errorFromThrown(e, 500) }; diff --git a/packages/services/service-datasource/src/admin-routes.ts b/packages/services/service-datasource/src/admin-routes.ts index d664c412f22..5e1cdce613b 100644 --- a/packages/services/service-datasource/src/admin-routes.ts +++ b/packages/services/service-datasource/src/admin-routes.ts @@ -83,7 +83,10 @@ const SERVICE_ERROR_CODE: Record = { * naming that service rather than the one this module is named after (#4225) — * and refusals surface as `400` with the service's message, under the * `error.code` registered for the service that refused (#4249): - * `DATASOURCE_ADMIN_ERROR` or `EXTERNAL_DATASOURCE_ERROR`. + * `DATASOURCE_ADMIN_ERROR` or `EXTERNAL_DATASOURCE_ERROR`. A name that resolves + * no stored datasource is not a refusal: every `datasource-admin` door that + * takes `:name` answers it `404` `RESOURCE_NOT_FOUND`, the read and the writes + * alike (see `badRequest`). * * Served by `datasource-admin`: * @@ -631,12 +634,23 @@ export function registerDatasourceAdminRoutes( * itself. `sendError`'s parameter type is the closed `ErrorCode` union, so * the code below is checked at compile time rather than trusted from the * throw site. + * + * A second relay of the same kind: a service that threw the `404` / + * `RESOURCE_NOT_FOUND` envelope has said the NAME resolves nothing, and is + * answered exactly as `GET /datasources/:name` answers that name — so an + * unknown datasource reads the same at every by-name door, and a caller can + * tell "no such datasource" from "your request was refused". The throwers + * today are the admin service's `updateDatasource`, `removeDatasource` and + * `migrateCredential` (`datasourceNotFoundError`). */ const badRequest = (res: any, service: ServiceName, err: unknown) => { const envelope = err as { code?: unknown; status?: unknown } | null | undefined; if (envelope?.status === 503 && envelope?.code === 'SERVICE_UNAVAILABLE') { return sendError(res, 503, 'SERVICE_UNAVAILABLE', (err as Error).message); } + if (envelope?.status === 404 && envelope?.code === 'RESOURCE_NOT_FOUND') { + return sendError(res, 404, 'RESOURCE_NOT_FOUND', (err as Error).message); + } return sendError(res, 400, SERVICE_ERROR_CODE[service], err instanceof Error ? err.message : String(err)); }; diff --git a/packages/services/service-datasource/src/datasource-admin-service.ts b/packages/services/service-datasource/src/datasource-admin-service.ts index 1c8772e150d..7c848f65543 100644 --- a/packages/services/service-datasource/src/datasource-admin-service.ts +++ b/packages/services/service-datasource/src/datasource-admin-service.ts @@ -332,6 +332,24 @@ function withRemaining(remaining: string[]): { remaining?: string[] } { * The loader messages ride on `cause` rather than in `message`: they name * internal datasources and tables, and the message is served to an API caller. */ +/** + * The refusal every by-name lifecycle door answers when `name` resolves no + * stored datasource — `updateDatasource`, `removeDatasource` and + * `migrateCredential`. + * + * It carries the ADR-0112 envelope (`404` / `RESOURCE_NOT_FOUND`) so the admin + * routes answer it exactly as `GET /datasources/:name` answers the same name. + * A plain `Error` here was rendered `400 DATASOURCE_ADMIN_ERROR`, which tells an + * SDK or AI caller its request was malformed rather than that the datasource + * does not exist — and the read sibling already said the latter. + */ +function datasourceNotFoundError(name: string): Error { + const err = new Error(`Datasource '${name}' not found.`) as Error & { code?: string; status?: number }; + err.code = 'RESOURCE_NOT_FOUND'; + err.status = 404; + return err; +} + function metadataIncompleteError(message: string, errors: string[]): Error { const err = new Error(message) as Error & { code?: string; status?: number; cause?: unknown }; err.code = 'SERVICE_UNAVAILABLE'; @@ -678,7 +696,7 @@ export class DatasourceAdminService implements IDatasourceAdminService { secret?: SecretInput, ): Promise { const existing = await this.config.getDatasourceRecord(name); - if (!existing) throw new Error(`Datasource '${name}' not found.`); + if (!existing) throw datasourceNotFoundError(name); if (existing.origin !== 'runtime') { throw new Error(`Datasource '${name}' is code-defined and cannot be edited at runtime.`); } @@ -849,7 +867,7 @@ export class DatasourceAdminService implements IDatasourceAdminService { */ async removeDatasource(name: string): Promise { const existing = await this.config.getDatasourceRecord(name); - if (!existing) throw new Error(`Datasource '${name}' not found.`); + if (!existing) throw datasourceNotFoundError(name); if (existing.origin !== 'runtime') { throw new Error(`Datasource '${name}' is code-defined and cannot be removed at runtime.`); } @@ -931,7 +949,7 @@ export class DatasourceAdminService implements IDatasourceAdminService { */ async migrateCredential(name: string): Promise { const existing = await this.config.getDatasourceRecord(name); - if (!existing) throw new Error(`Datasource '${name}' not found.`); + if (!existing) throw datasourceNotFoundError(name); const plan = planCredentialMigration(existing); if (plan.action === 'refuse') { diff --git a/packages/services/service-datasource/src/datasource-route-ledger.ts b/packages/services/service-datasource/src/datasource-route-ledger.ts index 724e9cee746..f1e6740dd05 100644 --- a/packages/services/service-datasource/src/datasource-route-ledger.ts +++ b/packages/services/service-datasource/src/datasource-route-ledger.ts @@ -134,7 +134,8 @@ export interface DatasourceRouteLedgerEntry { export const DATASOURCE_ROUTE_LEDGER: readonly DatasourceRouteLedgerEntry[] = [ // ── runtime datasource lifecycle (ADR-0015 Addendum §3.5) ───────────────── // Served by `datasource-admin`; 503 SERVICE_UNAVAILABLE when that service is - // not wired, 400 DATASOURCE_ADMIN_ERROR on a refusal (#4249). + // not wired, 400 DATASOURCE_ADMIN_ERROR on a refusal (#4249), and 404 + // RESOURCE_NOT_FOUND at every by-name door when the name resolves nothing. { route: 'GET /api/v1/datasources', family: 'datasource-lifecycle', disposition: 'server-only', note: 'Setup → Datasources list: provenance (code/runtime) + the retained connect verdict per datasource, so a datasource that failed to connect reports `error` here instead of looking untested. Console surface; the SDK expresses no datasource-lifecycle method.' }, { route: 'GET /api/v1/datasources/:name', family: 'datasource-lifecycle', disposition: 'server-only', From 1a3b1feff4c93554308e1892a770b8058a447638 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:43:28 +0000 Subject: [PATCH 2/6] test: pin 404 RESOURCE_NOT_FOUND parity at the draft, runs and datasource by-name doors Claude-Session: https://claude.ai/code/session_01X15AahTqrmeh8gAVdpKMCK Co-authored-by: Claude --- .../automation-runs-unknown-flow.test.ts | 100 ++++++++++++ ...ckages-draft-doors-unknown-package.test.ts | 144 +++++++++++++++++ .../datasource-admin-service.test.ts | 12 +- .../__tests__/datasource-unknown-name.test.ts | 153 ++++++++++++++++++ .../__tests__/envelope.conformance.test.ts | 10 +- 5 files changed, 409 insertions(+), 10 deletions(-) create mode 100644 packages/runtime/src/domains/automation-runs-unknown-flow.test.ts create mode 100644 packages/runtime/src/domains/packages-draft-doors-unknown-package.test.ts create mode 100644 packages/services/service-datasource/src/__tests__/datasource-unknown-name.test.ts diff --git a/packages/runtime/src/domains/automation-runs-unknown-flow.test.ts b/packages/runtime/src/domains/automation-runs-unknown-flow.test.ts new file mode 100644 index 00000000000..6f2132a182e --- /dev/null +++ b/packages/runtime/src/domains/automation-runs-unknown-flow.test.ts @@ -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) => ({ + runs: [] as unknown[], + hasMore: false, + })); + const execute = vi.fn(async () => ({ success: true, output: {} })); + const services: Record = { 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 })); + }); +}); diff --git a/packages/runtime/src/domains/packages-draft-doors-unknown-package.test.ts b/packages/runtime/src/domains/packages-draft-doors-unknown-package.test.ts new file mode 100644 index 00000000000..0d42538a18f --- /dev/null +++ b/packages/runtime/src/domains/packages-draft-doors-unknown-package.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it, vi } from 'vitest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +/** + * `POST /packages/:id/publish-drafts` and its twin `POST + * /packages/:id/discard-drafts` answer `404 RESOURCE_NOT_FOUND` for a package + * the registry does not hold — the answer `GET /packages/:id` gives the same id. + * + * Both answered `200`: `{ success: false, outcome: 'nothing_to_publish' }` and + * `{ success: true, discardedCount: 0 }` — the bodies a package that EXISTS and + * has nothing pending gets, so an SDK or AI caller could not tell "no such + * package" from "nothing to do". The draft doors now ask the registry lookup + * their read sibling answers from (`packageIsUnknown`), and the parity cases + * read both doors side by side so a future divergence fails here. + * + * Asked only when the protocol's answer is empty: drafts the protocol holds for + * an id the registry lacks are still published (the control below), so the + * change touches exactly the answer that lied. + */ + +const PKG_ADMIN = () => ({ + request: {}, + executionContext: { + userId: 'u_pkg_admin', + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }, +}) as any; + +const KNOWN = 'com.example.app'; +const UNKNOWN = 'com.example.nope'; + +const NOTHING_TO_PUBLISH = { + success: false, outcome: 'nothing_to_publish', publishedCount: 0, failedCount: 0, + published: [], failed: [], +}; +const PUBLISHED_ONE = { + success: true, outcome: 'published', publishedCount: 1, failedCount: 0, + published: [{ type: 'flow', name: 'nightly_rollup', version: 'sha256:aa11' }], failed: [], +}; +const NOTHING_DISCARDED = { success: true, discardedCount: 0, failedCount: 0, discarded: [], failed: [] }; + +function makeDoor(opts: { + publishResult?: Record; + apps?: Array>; +} = {}) { + const publishPackageDrafts = vi.fn().mockImplementation(async () => + JSON.parse(JSON.stringify(opts.publishResult ?? NOTHING_TO_PUBLISH))); + const discardPackageDrafts = vi.fn().mockImplementation(async () => ({ ...NOTHING_DISCARDED })); + const apps = opts.apps ?? []; + const getMetaItems = vi.fn().mockImplementation(async () => ({ items: apps.map((a) => ({ ...a })) })); + const saveMetaItem = vi.fn().mockImplementation(async () => ({ ok: true })); + const packages = new Map([[KNOWN, { id: KNOWN, manifest: { id: KNOWN, name: 'App', version: '1.0.0' }, enabled: true }]]); + const kernel: any = { + getService: (name: string) => { + if (name === 'protocol') { + return Promise.resolve({ publishPackageDrafts, discardPackageDrafts, getMetaItems, saveMetaItem }); + } + if (name === 'objectql') { + return Promise.resolve({ + insert: vi.fn(), find: vi.fn(), update: vi.fn(), + registry: { + getAllPackages: vi.fn().mockReturnValue([...packages.values()]), + getPackage: vi.fn((id: string) => packages.get(id)), + }, + }); + } + if (name === 'metadata') return Promise.resolve({ getObject: vi.fn() }); + return null; + }, + context: { getService: () => null, trigger: vi.fn() }, + }; + const send = (path: string, method: string, body: unknown = {}) => + new HttpDispatcher(kernel).handlePackages(path, method, body, {}, PKG_ADMIN()); + return { send, publishPackageDrafts, discardPackageDrafts, saveMetaItem }; +} + +function answer(result: any) { + return { + status: result.response?.status, + code: result.response?.body?.error?.code, + message: result.response?.body?.error?.message, + }; +} + +describe('the package draft doors answer an unknown package 404, like GET /packages/:id', () => { + it('publish-drafts: 404 RESOURCE_NOT_FOUND naming the package, never 200 nothing_to_publish', async () => { + const door = makeDoor(); + const result = await door.send(`/${UNKNOWN}/publish-drafts`, 'POST'); + + 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(UNKNOWN); + }); + + it('discard-drafts: 404 RESOURCE_NOT_FOUND naming the package, never 200 discardedCount 0', async () => { + const door = makeDoor(); + const result = await door.send(`/${UNKNOWN}/discard-drafts`, 'POST'); + + expect(result.response?.status).toBe(404); + expect(result.response?.body?.error?.code).toBe('RESOURCE_NOT_FOUND'); + expect(result.response?.body?.error?.message).toContain(UNKNOWN); + }); + + it('both draft doors give the read sibling\'s answer for the same id — status, code and message', async () => { + const door = makeDoor(); + const read = answer(await door.send(`/${UNKNOWN}`, 'GET', undefined)); + expect(read).toMatchObject({ status: 404, code: 'RESOURCE_NOT_FOUND' }); + + expect(answer(await door.send(`/${UNKNOWN}/publish-drafts`, 'POST'))).toEqual(read); + expect(answer(await door.send(`/${UNKNOWN}/discard-drafts`, 'POST'))).toEqual(read); + }); + + it('control: a package that exists with nothing pending still answers 200 at both doors', async () => { + const door = makeDoor(); + + const published = await door.send(`/${KNOWN}/publish-drafts`, 'POST'); + expect(published.response?.status).toBe(200); + expect(published.response?.body?.data?.outcome).toBe('nothing_to_publish'); + + const discarded = await door.send(`/${KNOWN}/discard-drafts`, 'POST'); + expect(discarded.response?.status).toBe(200); + expect(discarded.response?.body?.data?.discardedCount).toBe(0); + }); + + it('control: drafts the protocol holds for an id the registry lacks are still published', async () => { + const door = makeDoor({ publishResult: PUBLISHED_ONE }); + const result = await door.send(`/${UNKNOWN}/publish-drafts`, 'POST'); + + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.outcome).toBe('published'); + }); + + it('control: an unpublished app the door un-hid for that id is reported, not answered 404', async () => { + const door = makeDoor({ apps: [{ name: 'crm', _unpublished: true }] }); + const result = await door.send(`/${UNKNOWN}/publish-drafts`, 'POST'); + + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.unhiddenApps).toEqual(['crm']); + expect(door.saveMetaItem).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts b/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts index f587fac8fbd..97f77cb6a0e 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts @@ -346,9 +346,9 @@ describe('updateDatasource', () => { ).rejects.toThrow(/code-defined/i); }); - it('rejects updating a missing datasource', async () => { + it('rejects updating a missing datasource with the not-found envelope', async () => { const { service } = makeHarness(); - await expect(service.updateDatasource('nope', { label: 'x' })).rejects.toThrow(/not found/i); + await expect(service.updateDatasource('nope', { label: 'x' })).rejects.toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404, message: "Datasource 'nope' not found." }); }); }); @@ -382,9 +382,9 @@ describe('removeDatasource', () => { await expect(service.removeDatasource('crm_primary')).rejects.toThrow(/code-defined/i); }); - it('rejects removing a missing datasource', async () => { + it('rejects removing a missing datasource with the not-found envelope', async () => { const { service } = makeHarness(); - await expect(service.removeDatasource('nope')).rejects.toThrow(/not found/i); + await expect(service.removeDatasource('nope')).rejects.toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404, message: "Datasource 'nope' not found." }); }); }); @@ -679,9 +679,9 @@ describe('migrateCredential (#8155)', () => { expect(h.store.get('warehouse')!.config?.passwd).toBe('stale'); }); - it('throws for an unknown datasource — the one arm the route answers 400 for', async () => { + it('throws the not-found envelope for an unknown datasource — the route answers it 404', async () => { const h = makeHarness(); - await expect(h.service.migrateCredential('nope')).rejects.toThrow("Datasource 'nope' not found."); + await expect(h.service.migrateCredential('nope')).rejects.toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404, message: "Datasource 'nope' not found." }); }); }); diff --git a/packages/services/service-datasource/src/__tests__/datasource-unknown-name.test.ts b/packages/services/service-datasource/src/__tests__/datasource-unknown-name.test.ts new file mode 100644 index 00000000000..18710b3317c --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/datasource-unknown-name.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; +import { registerDatasourceAdminRoutes } from '../admin-routes.js'; +import { + DatasourceAdminService, + type DatasourceAdminServiceConfig, + type StoredDatasource, +} from '../datasource-admin-service.js'; +import { + ENTITLED_CREDENTIAL, + createSessionAuthService, + createGrantsEngine, +} from './entitled-caller.fixture.js'; + +/** + * An unknown datasource name answers the SAME `404 RESOURCE_NOT_FOUND` at + * every `datasource-admin` door that takes `:name`. + * + * `GET /datasources/:name` already answered 404 for a name that resolves no + * stored datasource, while `PATCH`, `DELETE` and `POST …/migrate-credential` + * answered the identical name `400 DATASOURCE_ADMIN_ERROR` — the service threw + * a plain `Error` and the route rendered every plain throw as a malformed + * request. An SDK or AI caller could not tell "no such datasource" from "your + * request was refused". + * + * Driven through the REAL routes over the REAL service, so each pin reads the + * envelope a caller sees, and the parity case reads all four doors side by + * side: a future door that diverges from its siblings fails here, not in QA. + */ + +const authService = createSessionAuthService(); +const grantsEngine = createGrantsEngine(); + +const WAREHOUSE: StoredDatasource = { + name: 'warehouse', + driver: 'sqlite', + origin: 'runtime', + config: { filename: ':memory:' }, +}; + +function makeDoor(seed: StoredDatasource[] = [WAREHOUSE]) { + const records = new Map(seed.map((r) => [r.name, structuredClone(r)])); + const writes: string[] = []; + const config: DatasourceAdminServiceConfig = { + probe: async () => ({ ok: true, latencyMs: 1 }), + listDatasourceRecords: async () => [...records.values()].map((r) => structuredClone(r)), + getDatasourceRecord: async (name) => { + const r = records.get(name); + return r ? structuredClone(r) : undefined; + }, + putDatasourceRecord: async (record) => { + writes.push(`put:${record.name}`); + records.set(record.name, structuredClone(record)); + }, + deleteDatasourceRecord: async (name) => { + writes.push(`delete:${name}`); + records.delete(name); + }, + writeSecret: async () => { + writes.push('writeSecret'); + return 'sys_secret://datasource/x#1'; + }, + removeSecret: async () => { + writes.push('removeSecret'); + }, + countBoundObjects: async () => 0, + listObjectsBoundTo: async () => [], + registerPool: () => {}, + }; + + const service = new DatasourceAdminService(config); + const server = new HonoHttpServer(0); + const ctx = { + getService: vi.fn((name: string) => + name === 'auth' + ? authService + : name === 'objectql' || name === 'data' + ? grantsEngine + : name === 'datasource-admin' + ? service + : undefined, + ), + } as any; + registerDatasourceAdminRoutes(server, ctx, '/api/v1'); + const app = server.getRawApp(); + + const send = (method: string, path: string, body?: unknown) => + app.fetch( + new Request(`http://local/api/v1${path}`, { + method, + headers: { 'content-type': 'application/json', authorization: ENTITLED_CREDENTIAL }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + ); + + return { send, records, writes }; +} + +/** Every `datasource-admin` door that takes `:name`, the read sibling first. */ +const BY_NAME_DOORS: ReadonlyArray<{ door: string; method: string; path: (name: string) => string; body?: unknown }> = [ + { door: 'GET /datasources/:name', method: 'GET', path: (n) => `/datasources/${n}` }, + { door: 'PATCH /datasources/:name', method: 'PATCH', path: (n) => `/datasources/${n}`, body: { config: {} } }, + { door: 'DELETE /datasources/:name', method: 'DELETE', path: (n) => `/datasources/${n}` }, + { door: 'POST /datasources/:name/migrate-credential', method: 'POST', path: (n) => `/datasources/${n}/migrate-credential`, body: {} }, +]; + +describe('an unknown datasource name is 404 RESOURCE_NOT_FOUND at every by-name admin door', () => { + for (const d of BY_NAME_DOORS) { + it(`${d.door} → 404 RESOURCE_NOT_FOUND naming the datasource, nothing written`, async () => { + const door = makeDoor(); + const res = await door.send(d.method, d.path('no_such_ds'), d.body); + const body = (await res.json()) as { success: boolean; error: { code: string; message: string } }; + + expect({ status: res.status, success: body.success, code: body.error.code }).toEqual({ + status: 404, + success: false, + code: 'RESOURCE_NOT_FOUND', + }); + expect(body.error.message).toContain('no_such_ds'); + expect(door.writes).toEqual([]); + expect([...door.records.keys()]).toEqual(['warehouse']); + }); + } + + it('all four doors give one answer for the same unknown name — the sibling read is the reference', async () => { + const door = makeDoor(); + const answers: Array<{ door: string; status: number; code: string }> = []; + for (const d of BY_NAME_DOORS) { + const res = await door.send(d.method, d.path('no_such_ds'), d.body); + const body = (await res.json()) as { error: { code: string } }; + answers.push({ door: d.door, status: res.status, code: body.error.code }); + } + const [reference] = answers; + expect(reference).toMatchObject({ status: 404, code: 'RESOURCE_NOT_FOUND' }); + for (const a of answers) { + expect({ status: a.status, code: a.code }, a.door).toEqual({ status: reference.status, code: reference.code }); + } + }); + + it('control: a datasource that exists is still patched, and a code-defined one is still refused 400', async () => { + const door = makeDoor([WAREHOUSE, { name: 'crm', driver: 'sqlite', origin: 'code', config: { filename: ':memory:' } }]); + + const patched = await door.send('PATCH', '/datasources/warehouse', { label: 'Warehouse' }); + expect(patched.status).toBe(200); + expect(door.records.get('warehouse')?.label).toBe('Warehouse'); + + const refused = await door.send('PATCH', '/datasources/crm', { label: 'Edited' }); + const body = (await refused.json()) as { error: { code: string } }; + expect({ status: refused.status, code: body.error.code }).toEqual({ status: 400, code: 'DATASOURCE_ADMIN_ERROR' }); + }); +}); diff --git a/packages/services/service-datasource/src/__tests__/envelope.conformance.test.ts b/packages/services/service-datasource/src/__tests__/envelope.conformance.test.ts index de2d223e5b7..024eba9c571 100644 --- a/packages/services/service-datasource/src/__tests__/envelope.conformance.test.ts +++ b/packages/services/service-datasource/src/__tests__/envelope.conformance.test.ts @@ -293,14 +293,16 @@ describe('datasource-admin envelope (#3843) — error bodies', () => { run: () => drive(mount({ removeDatasource: async () => { throw new Error('not runtime-origin'); } }), '/api/v1/datasources/pg', { method: 'DELETE' }), }, { - // The credential migration's THROW arm — an unknown name, a store that - // failed — as distinct from its `refused` result, which is a 200 above. + // The credential migration's THROW arm — a store that failed — as + // distinct from its `refused` result, which is a 200 above. An unknown + // name is not this arm: the service throws it with the not-found + // envelope and the route answers it 404 (`datasource-unknown-name.test.ts`). name: 'a credential-migration failure', status: 400, code: 'DATASOURCE_ADMIN_ERROR', run: () => drive( - mount({ migrateCredential: async () => { throw new Error("Datasource 'nope' not found."); } }), - '/api/v1/datasources/nope/migrate-credential', + mount({ migrateCredential: async () => { throw new Error('secret store offline'); } }), + '/api/v1/datasources/warehouse/migrate-credential', { method: 'POST', body: '{}' }, ), }, From 8247e6c5bfa518f6af8dee57aea5fd9eae13b7b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:57:56 +0000 Subject: [PATCH 3/6] chore: changeset for unknown-resource 404 at the draft, runs and datasource doors Claude-Session: https://claude.ai/code/session_01X15AahTqrmeh8gAVdpKMCK Co-authored-by: Claude --- .changeset/25486-unknown-resource-404.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/25486-unknown-resource-404.md diff --git a/.changeset/25486-unknown-resource-404.md b/.changeset/25486-unknown-resource-404.md new file mode 100644 index 00000000000..3b6de40849c --- /dev/null +++ b/.changeset/25486-unknown-resource-404.md @@ -0,0 +1,18 @@ +--- +'@objectstack/runtime': patch +'@objectstack/service-datasource': patch +--- + +fix(rest): an unknown resource answers `404 RESOURCE_NOT_FOUND` at the package draft doors, the flow runs list and the datasource by-name write doors, as their read siblings already did + +Clause-②: no +Metadata-protocol: none + +Six 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`, naming the missing resource, in the same words as its sibling: + +- `POST /api/v1/packages/:id/publish-drafts` for a package the registry does not hold: was `200 { success: false, outcome: 'nothing_to_publish' }`, now `404 "Package '' not found"`, the answer of `GET /api/v1/packages/:id`. `nothing_to_publish` is kept for a package that exists and has nothing pending. Drafts the protocol does hold for such an id are still published as before. +- `POST /api/v1/packages/:id/discard-drafts`, the same door's twin: was `200 { discardedCount: 0 }`, now the same `404`. +- `GET /api/v1/automation/:name/runs` for an unknown flow: was `200 { runs: [], hasMore: false }`, now `404 "Flow '' not found"`, the answer of `POST /api/v1/automation/:name/trigger`. An empty page is kept for a flow that exists and has not run. +- `PATCH`, `DELETE /api/v1/datasources/:name` and `POST /api/v1/datasources/:name/migrate-credential` for an unknown datasource: were `400 DATASOURCE_ADMIN_ERROR`, now `404`, the answer of `GET /api/v1/datasources/:name`. A code-defined datasource is still refused `400 DATASOURCE_ADMIN_ERROR`. The datasource-admin service now throws its not-found refusal with the `404` / `RESOURCE_NOT_FOUND` envelope, so an embedder calling `updateDatasource`, `removeDatasource` or `migrateCredential` directly can read `err.code` and `err.status`; the message is unchanged. + +`ObjectStackClient` already throws on any non-2xx answer, so `packages.publishDrafts`, `packages.discardDrafts` and `automation.listRuns` now reject with the not-found message for an unknown id instead of resolving an empty result. From 71d6c23c4147b5d5e585e66d4b58bb5a91fe65fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:58:11 +0000 Subject: [PATCH 4/6] fix(rest): answer 404 RESOURCE_NOT_FOUND at DELETE /automation/:name and GET /packages/:id/commits for an unknown resource Claude-Session: https://claude.ai/code/session_01X15AahTqrmeh8gAVdpKMCK Co-authored-by: Claude --- .changeset/25486-unknown-resource-404.md | 8 +- .../automation-delete-unknown-flow.test.ts | 99 +++++++++++++++++++ packages/runtime/src/domains/automation.ts | 18 +++- ...ckages-draft-doors-unknown-package.test.ts | 50 +++++++++- packages/runtime/src/domains/packages.ts | 19 ++-- 5 files changed, 178 insertions(+), 16 deletions(-) create mode 100644 packages/runtime/src/domains/automation-delete-unknown-flow.test.ts diff --git a/.changeset/25486-unknown-resource-404.md b/.changeset/25486-unknown-resource-404.md index 3b6de40849c..be5d58e1b22 100644 --- a/.changeset/25486-unknown-resource-404.md +++ b/.changeset/25486-unknown-resource-404.md @@ -3,16 +3,18 @@ '@objectstack/service-datasource': patch --- -fix(rest): an unknown resource answers `404 RESOURCE_NOT_FOUND` at the package draft doors, the flow runs list and the datasource by-name write doors, as their read siblings already did +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 Metadata-protocol: none -Six 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`, naming the missing resource, in the same words as its sibling: +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`, naming the missing resource, in the same words as its sibling: - `POST /api/v1/packages/:id/publish-drafts` for a package the registry does not hold: was `200 { success: false, outcome: 'nothing_to_publish' }`, now `404 "Package '' not found"`, the answer of `GET /api/v1/packages/:id`. `nothing_to_publish` is kept for a package that exists and has nothing pending. Drafts the protocol does hold for such an id are still published as before. - `POST /api/v1/packages/:id/discard-drafts`, the same door's twin: was `200 { discardedCount: 0 }`, now the same `404`. +- `GET /api/v1/packages/:id/commits` for an unknown package: was `200 { commits: [] }`, now the same `404`. A trail that outlived its package's registry entry is still listed. +- `DELETE /api/v1/automation/:name` for a flow neither the engine nor the metadata store holds: was `200 { name, deleted: true }`, now `404 "Flow '' not found"`. A stored flow the engine does not hold is still deleted and answered `200`. - `GET /api/v1/automation/:name/runs` for an unknown flow: was `200 { runs: [], hasMore: false }`, now `404 "Flow '' not found"`, the answer of `POST /api/v1/automation/:name/trigger`. An empty page is kept for a flow that exists and has not run. - `PATCH`, `DELETE /api/v1/datasources/:name` and `POST /api/v1/datasources/:name/migrate-credential` for an unknown datasource: were `400 DATASOURCE_ADMIN_ERROR`, now `404`, the answer of `GET /api/v1/datasources/:name`. A code-defined datasource is still refused `400 DATASOURCE_ADMIN_ERROR`. The datasource-admin service now throws its not-found refusal with the `404` / `RESOURCE_NOT_FOUND` envelope, so an embedder calling `updateDatasource`, `removeDatasource` or `migrateCredential` directly can read `err.code` and `err.status`; the message is unchanged. -`ObjectStackClient` already throws on any non-2xx answer, so `packages.publishDrafts`, `packages.discardDrafts` and `automation.listRuns` now reject with the not-found message for an unknown id instead of resolving an empty result. +`ObjectStackClient` already throws on any non-2xx answer, so `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. diff --git a/packages/runtime/src/domains/automation-delete-unknown-flow.test.ts b/packages/runtime/src/domains/automation-delete-unknown-flow.test.ts new file mode 100644 index 00000000000..298d88982b6 --- /dev/null +++ b/packages/runtime/src/domains/automation-delete-unknown-flow.test.ts @@ -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 = { + 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); + }); +}); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index ab7f3544156..ceee274ee68 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -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 @@ -1886,16 +1887,27 @@ async function unregisterAndDeleteFlow( const store: (Partial> & Partial>) | 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; } @@ -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 diff --git a/packages/runtime/src/domains/packages-draft-doors-unknown-package.test.ts b/packages/runtime/src/domains/packages-draft-doors-unknown-package.test.ts index 0d42538a18f..16de005f1ab 100644 --- a/packages/runtime/src/domains/packages-draft-doors-unknown-package.test.ts +++ b/packages/runtime/src/domains/packages-draft-doors-unknown-package.test.ts @@ -4,12 +4,13 @@ import { describe, expect, it, vi } from 'vitest'; import { HttpDispatcher } from '../http-dispatcher.js'; /** - * `POST /packages/:id/publish-drafts` and its twin `POST - * /packages/:id/discard-drafts` answer `404 RESOURCE_NOT_FOUND` for a package - * the registry does not hold — the answer `GET /packages/:id` gives the same id. + * `POST /packages/:id/publish-drafts`, its twin `POST + * /packages/:id/discard-drafts` and the commit trail `GET + * /packages/:id/commits` answer `404 RESOURCE_NOT_FOUND` for a package the + * registry does not hold — the answer `GET /packages/:id` gives the same id. * * Both answered `200`: `{ success: false, outcome: 'nothing_to_publish' }` and - * `{ success: true, discardedCount: 0 }` — the bodies a package that EXISTS and + * `{ success: true, discardedCount: 0 }` (and `{ commits: [] }`) — the bodies a package that EXISTS and * has nothing pending gets, so an SDK or AI caller could not tell "no such * package" from "nothing to do". The draft doors now ask the registry lookup * their read sibling answers from (`packageIsUnknown`), and the parity cases @@ -44,6 +45,8 @@ const NOTHING_DISCARDED = { success: true, discardedCount: 0, failedCount: 0, di function makeDoor(opts: { publishResult?: Record; apps?: Array>; + /** Commit ids the trail holds, per package id. */ + commits?: Record; } = {}) { const publishPackageDrafts = vi.fn().mockImplementation(async () => JSON.parse(JSON.stringify(opts.publishResult ?? NOTHING_TO_PUBLISH))); @@ -51,11 +54,15 @@ function makeDoor(opts: { const apps = opts.apps ?? []; const getMetaItems = vi.fn().mockImplementation(async () => ({ items: apps.map((a) => ({ ...a })) })); const saveMetaItem = vi.fn().mockImplementation(async () => ({ ok: true })); + // The commit trail answers `[]` for ANY id it has no commits for — the + // shape that let an unknown package read as one with no history. + const listCommits = vi.fn().mockImplementation(async ({ packageId }: { packageId: string }) => + (opts.commits?.[packageId] ?? []).map((id) => ({ id, packageId }))); const packages = new Map([[KNOWN, { id: KNOWN, manifest: { id: KNOWN, name: 'App', version: '1.0.0' }, enabled: true }]]); const kernel: any = { getService: (name: string) => { if (name === 'protocol') { - return Promise.resolve({ publishPackageDrafts, discardPackageDrafts, getMetaItems, saveMetaItem }); + return Promise.resolve({ publishPackageDrafts, discardPackageDrafts, getMetaItems, saveMetaItem, listCommits }); } if (name === 'objectql') { return Promise.resolve({ @@ -142,3 +149,36 @@ describe('the package draft doors answer an unknown package 404, like GET /packa expect(door.saveMetaItem).toHaveBeenCalledTimes(1); }); }); + +describe('GET /packages/:id/commits answers an unknown package 404, like GET /packages/:id', () => { + it('answers 404 RESOURCE_NOT_FOUND naming the package, never 200 commits []', async () => { + const door = makeDoor(); + const result = await door.send(`/${UNKNOWN}/commits`, 'GET', undefined); + + expect(result.response?.status).toBe(404); + expect(result.response?.body?.error?.code).toBe('RESOURCE_NOT_FOUND'); + expect(result.response?.body?.error?.message).toContain(UNKNOWN); + }); + + it('gives the read sibling\'s answer for the same id — status, code and message', async () => { + const door = makeDoor(); + const read = answer(await door.send(`/${UNKNOWN}`, 'GET', undefined)); + expect(answer(await door.send(`/${UNKNOWN}/commits`, 'GET', undefined))).toEqual(read); + }); + + it('control: a package that exists with no commits is still 200 []', async () => { + const door = makeDoor(); + const result = await door.send(`/${KNOWN}/commits`, 'GET', undefined); + + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.commits).toEqual([]); + }); + + it('control: the trail of an id the registry no longer holds is still served when it has commits', async () => { + const door = makeDoor({ commits: { [UNKNOWN]: ['cmt_01'] } }); + const result = await door.send(`/${UNKNOWN}/commits`, 'GET', undefined); + + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.commits).toEqual([{ id: 'cmt_01', packageId: UNKNOWN }]); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 46b627e3b0f..8808fa50da2 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -512,12 +512,13 @@ async function refuseWalledCatalogPackageWrite( * registry too (`POST /packages`, and `service-package` at boot), so a Studio * app is never "unknown" here. * - * Read by `publish-drafts` and `discard-drafts` only AFTER the protocol has - * answered, and only when that answer is empty: drafts the protocol does hold - * for an id the registry lacks are still published or discarded exactly as - * before. What changes is the one answer that lied — `200` "nothing to - * publish" / "nothing discarded" for a package that is not there, which an SDK - * or AI caller cannot tell apart from a real, empty package. + * Read by `publish-drafts`, `discard-drafts` and `GET /:id/commits` only + * AFTER the protocol has answered, and only when that answer is empty: drafts + * or commits the protocol does hold for an id the registry lacks are still + * published, discarded or listed exactly as before. What changes is the one + * answer that lied — `200` "nothing to publish" / "nothing discarded" / "no + * commits" for a package that is not there, which an SDK or AI caller cannot + * tell apart from a real, empty package. * * A registry without `getPackage` cannot be asked, so this answers `false` * ("no evidence of absence") rather than invent a 404. @@ -2065,6 +2066,12 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin const commits = await protocol.listCommits({ packageId: id, }); + // An empty trail is the answer for a package that exists — + // see `packageIsUnknown`. A trail that outlived its + // package's registry entry is still served. + if (Array.isArray(commits) && commits.length === 0 && packageIsUnknown(registry, id)) { + return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; + } return { handled: true, response: deps.success({ commits }) }; } catch (e: any) { return { handled: true, response: deps.errorFromThrown(e, 500) }; From a2f19a47ebd7d01c22a949d97398dd3530bed65a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:40:38 +0000 Subject: [PATCH 5/6] fix(rest): declare the narrowing, align datasource not-found words with GET, keep a failed flip's unhideError Claude-Session: https://claude.ai/code/session_01X15AahTqrmeh8gAVdpKMCK Co-authored-by: Claude --- .changeset/25486-unknown-resource-404.md | 26 +++++++++------ ...ckages-draft-doors-unknown-package.test.ts | 32 +++++++++++++++++-- packages/runtime/src/domains/packages.ts | 5 ++- .../datasource-admin-service.test.ts | 6 ++-- .../__tests__/datasource-unknown-name.test.ts | 11 ++++--- .../src/datasource-admin-service.ts | 3 +- 6 files changed, 62 insertions(+), 21 deletions(-) diff --git a/.changeset/25486-unknown-resource-404.md b/.changeset/25486-unknown-resource-404.md index be5d58e1b22..43a8e61f6b7 100644 --- a/.changeset/25486-unknown-resource-404.md +++ b/.changeset/25486-unknown-resource-404.md @@ -5,16 +5,24 @@ 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 +Clause-②: no (narrowing) Metadata-protocol: none -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`, naming the missing resource, in the same words as its sibling: + -- `POST /api/v1/packages/:id/publish-drafts` for a package the registry does not hold: was `200 { success: false, outcome: 'nothing_to_publish' }`, now `404 "Package '' not found"`, the answer of `GET /api/v1/packages/:id`. `nothing_to_publish` is kept for a package that exists and has nothing pending. Drafts the protocol does hold for such an id are still published as before. -- `POST /api/v1/packages/:id/discard-drafts`, the same door's twin: was `200 { discardedCount: 0 }`, now the same `404`. -- `GET /api/v1/packages/:id/commits` for an unknown package: was `200 { commits: [] }`, now the same `404`. A trail that outlived its package's registry entry is still listed. -- `DELETE /api/v1/automation/:name` for a flow neither the engine nor the metadata store holds: was `200 { name, deleted: true }`, now `404 "Flow '' not found"`. A stored flow the engine does not hold is still deleted and answered `200`. -- `GET /api/v1/automation/:name/runs` for an unknown flow: was `200 { runs: [], hasMore: false }`, now `404 "Flow '' not found"`, the answer of `POST /api/v1/automation/:name/trigger`. An empty page is kept for a flow that exists and has not run. -- `PATCH`, `DELETE /api/v1/datasources/:name` and `POST /api/v1/datasources/:name/migrate-credential` for an unknown datasource: were `400 DATASOURCE_ADMIN_ERROR`, now `404`, the answer of `GET /api/v1/datasources/:name`. A code-defined datasource is still refused `400 DATASOURCE_ADMIN_ERROR`. The datasource-admin service now throws its not-found refusal with the `404` / `RESOURCE_NOT_FOUND` envelope, so an embedder calling `updateDatasource`, `removeDatasource` or `migrateCredential` directly can read `err.code` and `err.status`; the message is unchanged. +**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. -`ObjectStackClient` already throws on any non-2xx answer, so `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. +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. FROM → TO, 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 '' 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 '' 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 '' not found."` → `404 RESOURCE_NOT_FOUND 'Datasource "" 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 "" does not exist.`. diff --git a/packages/runtime/src/domains/packages-draft-doors-unknown-package.test.ts b/packages/runtime/src/domains/packages-draft-doors-unknown-package.test.ts index 16de005f1ab..bf1dc535610 100644 --- a/packages/runtime/src/domains/packages-draft-doors-unknown-package.test.ts +++ b/packages/runtime/src/domains/packages-draft-doors-unknown-package.test.ts @@ -44,15 +44,21 @@ const NOTHING_DISCARDED = { success: true, discardedCount: 0, failedCount: 0, di function makeDoor(opts: { publishResult?: Record; + discardResult?: Record; apps?: Array>; + /** Make the ADR-0045 visibility flip's app read throw. */ + failAppRead?: boolean; /** Commit ids the trail holds, per package id. */ commits?: Record; } = {}) { const publishPackageDrafts = vi.fn().mockImplementation(async () => JSON.parse(JSON.stringify(opts.publishResult ?? NOTHING_TO_PUBLISH))); - const discardPackageDrafts = vi.fn().mockImplementation(async () => ({ ...NOTHING_DISCARDED })); + const discardPackageDrafts = vi.fn().mockImplementation(async () => ({ ...(opts.discardResult ?? NOTHING_DISCARDED) })); const apps = opts.apps ?? []; - const getMetaItems = vi.fn().mockImplementation(async () => ({ items: apps.map((a) => ({ ...a })) })); + const getMetaItems = vi.fn().mockImplementation(async () => { + if (opts.failAppRead) throw new Error('meta backend down'); + return { items: apps.map((a) => ({ ...a })) }; + }); const saveMetaItem = vi.fn().mockImplementation(async () => ({ ok: true })); // The commit trail answers `[]` for ANY id it has no commits for — the // shape that let an unknown package read as one with no history. @@ -140,6 +146,27 @@ describe('the package draft doors answer an unknown package 404, like GET /packa expect(result.response?.body?.data?.outcome).toBe('published'); }); + it('control: a visibility flip that FAILED for that id is answered 200 with its unhideError, never hidden behind a 404', async () => { + const door = makeDoor({ failAppRead: true }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = await door.send(`/${UNKNOWN}/publish-drafts`, 'POST'); + + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.outcome).toBe('nothing_to_publish'); + expect(result.response?.body?.data?.unhideError).toEqual(expect.any(String)); + vi.restoreAllMocks(); + }); + + it('control: drafts the protocol holds for an id the registry lacks are still discarded, 200', async () => { + const door = makeDoor({ + discardResult: { success: true, discardedCount: 1, failedCount: 0, discarded: [{ type: 'view', name: 'orphan_view' }], failed: [] }, + }); + const result = await door.send(`/${UNKNOWN}/discard-drafts`, 'POST'); + + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.discardedCount).toBe(1); + }); + it('control: an unpublished app the door un-hid for that id is reported, not answered 404', async () => { const door = makeDoor({ apps: [{ name: 'crm', _unpublished: true }] }); const result = await door.send(`/${UNKNOWN}/publish-drafts`, 'POST'); @@ -163,6 +190,7 @@ describe('GET /packages/:id/commits answers an unknown package 404, like GET /pa it('gives the read sibling\'s answer for the same id — status, code and message', async () => { const door = makeDoor(); const read = answer(await door.send(`/${UNKNOWN}`, 'GET', undefined)); + expect(read).toMatchObject({ status: 404, code: 'RESOURCE_NOT_FOUND' }); expect(answer(await door.send(`/${UNKNOWN}/commits`, 'GET', undefined))).toEqual(read); }); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 8808fa50da2..7d8498c3c98 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -1929,10 +1929,13 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (flipped.length > 0) (result as any).unhiddenApps = flipped; // A request that touched nothing about a package this // registry does not hold is `404`, never `200 - // nothing_to_publish` — see `packageIsUnknown`. + // nothing_to_publish` — see `packageIsUnknown`. A flip that + // FAILED did not establish that nothing is bound to the id, + // so its recorded `unhideError` is answered, never hidden. if ( (result as any)?.outcome === 'nothing_to_publish' && flipped.length === 0 + && (result as any)?.unhideError === undefined && packageIsUnknown(registry, id) ) { return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; diff --git a/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts b/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts index 97f77cb6a0e..e45dbdf9ad6 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts @@ -348,7 +348,7 @@ describe('updateDatasource', () => { it('rejects updating a missing datasource with the not-found envelope', async () => { const { service } = makeHarness(); - await expect(service.updateDatasource('nope', { label: 'x' })).rejects.toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404, message: "Datasource 'nope' not found." }); + await expect(service.updateDatasource('nope', { label: 'x' })).rejects.toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404, message: 'Datasource "nope" does not exist.' }); }); }); @@ -384,7 +384,7 @@ describe('removeDatasource', () => { it('rejects removing a missing datasource with the not-found envelope', async () => { const { service } = makeHarness(); - await expect(service.removeDatasource('nope')).rejects.toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404, message: "Datasource 'nope' not found." }); + await expect(service.removeDatasource('nope')).rejects.toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404, message: 'Datasource "nope" does not exist.' }); }); }); @@ -681,7 +681,7 @@ describe('migrateCredential (#8155)', () => { it('throws the not-found envelope for an unknown datasource — the route answers it 404', async () => { const h = makeHarness(); - await expect(h.service.migrateCredential('nope')).rejects.toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404, message: "Datasource 'nope' not found." }); + await expect(h.service.migrateCredential('nope')).rejects.toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404, message: 'Datasource "nope" does not exist.' }); }); }); diff --git a/packages/services/service-datasource/src/__tests__/datasource-unknown-name.test.ts b/packages/services/service-datasource/src/__tests__/datasource-unknown-name.test.ts index 18710b3317c..868aad36d9d 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-unknown-name.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-unknown-name.test.ts @@ -124,18 +124,19 @@ describe('an unknown datasource name is 404 RESOURCE_NOT_FOUND at every by-name }); } - it('all four doors give one answer for the same unknown name — the sibling read is the reference', async () => { + it('all four doors give one answer for the same unknown name — status, code and message; the sibling read is the reference', async () => { const door = makeDoor(); - const answers: Array<{ door: string; status: number; code: string }> = []; + const answers: Array<{ door: string; status: number; code: string; message: string }> = []; for (const d of BY_NAME_DOORS) { const res = await door.send(d.method, d.path('no_such_ds'), d.body); - const body = (await res.json()) as { error: { code: string } }; - answers.push({ door: d.door, status: res.status, code: body.error.code }); + const body = (await res.json()) as { error: { code: string; message: string } }; + answers.push({ door: d.door, status: res.status, code: body.error.code, message: body.error.message }); } const [reference] = answers; expect(reference).toMatchObject({ status: 404, code: 'RESOURCE_NOT_FOUND' }); for (const a of answers) { - expect({ status: a.status, code: a.code }, a.door).toEqual({ status: reference.status, code: reference.code }); + expect({ status: a.status, code: a.code, message: a.message }, a.door) + .toEqual({ status: reference.status, code: reference.code, message: reference.message }); } }); diff --git a/packages/services/service-datasource/src/datasource-admin-service.ts b/packages/services/service-datasource/src/datasource-admin-service.ts index 7c848f65543..d15b6c9f309 100644 --- a/packages/services/service-datasource/src/datasource-admin-service.ts +++ b/packages/services/service-datasource/src/datasource-admin-service.ts @@ -344,7 +344,8 @@ function withRemaining(remaining: string[]): { remaining?: string[] } { * does not exist — and the read sibling already said the latter. */ function datasourceNotFoundError(name: string): Error { - const err = new Error(`Datasource '${name}' not found.`) as Error & { code?: string; status?: number }; + // GET's own words, so every by-name door names the missing datasource alike. + const err = new Error(`Datasource "${name}" does not exist.`) as Error & { code?: string; status?: number }; err.code = 'RESOURCE_NOT_FOUND'; err.status = 404; return err; From f3e50da5f457813415390a24b0daa84085e2cb05 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:42:03 +0000 Subject: [PATCH 6/6] chore: changeset words the per-door answers as before/after, not a migration label Claude-Session: https://claude.ai/code/session_01X15AahTqrmeh8gAVdpKMCK Co-authored-by: Claude --- .changeset/25486-unknown-resource-404.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/25486-unknown-resource-404.md b/.changeset/25486-unknown-resource-404.md index 43a8e61f6b7..22413fd7b10 100644 --- a/.changeset/25486-unknown-resource-404.md +++ b/.changeset/25486-unknown-resource-404.md @@ -12,7 +12,7 @@ Metadata-protocol: none **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. FROM → TO, per door: +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 '' 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`.