diff --git a/.changeset/lazy-pugs-shake.md b/.changeset/lazy-pugs-shake.md new file mode 100644 index 0000000000..79201dbdcf --- /dev/null +++ b/.changeset/lazy-pugs-shake.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": minor +--- + +`ApiEndpoint.target` is now **optional** in the vocabulary (#10338, maintainer ruling +2026-08-23). The key was required on every endpoint but read only for `type: 'flow'` +(executor, OpenAPI enrichment and the publish gate all address an `object_operation` +via `objectParams.object` / `.operation`) — so an `object_operation` author was forced +to write a dead string nothing consumed or cross-checked. Authoring guidance: omit +`target` on `object_operation` endpoints. The publish gate still **requires** `target` +for `type: 'flow'` — a flow endpoint that names no target flow is refused at publish, +and the runtime's structural backstop answers `501 NOT_IMPLEMENTED` for one that +reached the store another way. No migration: this is a pure widening — every previously +valid declaration (all of which carry a string `target`) still parses unchanged. diff --git a/content/docs/api/declarative-endpoints.mdx b/content/docs/api/declarative-endpoints.mdx index bcd289eca3..9522f836a1 100644 --- a/content/docs/api/declarative-endpoints.mdx +++ b/content/docs/api/declarative-endpoints.mdx @@ -68,10 +68,10 @@ export default defineStack({ summary: 'Lead feed', description: 'Open leads, for the partner portal.', type: 'object_operation', - // `target` is required on every entry by the vocabulary. An - // object_operation is addressed by `objectParams`, so nothing reads this - // one — name the object it works on and keep the two in step. - target: 'acme_lead', + // No `target` — an object_operation is addressed by `objectParams` + // alone, so the key is unread for this type and omitting it is the + // correct spelling. `target` is required (at publish) only for + // `type: 'flow'`, as `acme_lead_intake` below shows. objectParams: { object: 'acme_lead', operation: 'find' }, // Omitting `authRequired` is the safe spelling — it defaults to `true`. cacheTtl: 30, @@ -149,10 +149,13 @@ under that mount. | `type: 'flow'` with no `target` | rejected | | `type: 'script'`, `type: 'proxy'` | rejected at publish | -`target` is required on **every** entry, whatever the `type`. A `flow` endpoint is executed -by it; an `object_operation` is addressed by `objectParams` instead, so nothing reads its -`target` — write the object name there and keep the two in step, because leaving the key -out is a type error before it is anything else. +`target` is **per-type**: a `flow` endpoint is executed by it, so publish requires it +there and refuses a flow that names no target flow. An `object_operation` is addressed by +`objectParams` instead — nothing reads its `target`, and the key is optional in the +vocabulary precisely so you can leave it out. Do: omit it on `object_operation` entries. +A `target` written there is a dead string nothing checks against `objectParams.object` — +a declaration whose first line says one object while `objectParams` serves another +publishes green, which is why the dead spelling is not worth teaching. `script` and `proxy` are **not servable declarations**. Nothing in the platform verifies that a script target is reachable, and forwarding to an arbitrary outbound URL is an diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 8f56d9de8f..44153c3c61 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -324,7 +324,8 @@ export const leadFeed: ApiEndpoint = { path: '/api/v1/apps/acme/leads', // /api/v1/apps// method: 'GET', type: 'object_operation', - target: 'acme_lead', + // No `target` — object_operation is addressed by `objectParams` alone; + // `target` is required (at publish) only for `type: 'flow'`. objectParams: { object: 'acme_lead', operation: 'find' }, // `authRequired` omitted → defaults to true (a session is required). cacheTtl: 30, diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index cd299fc01a..21e6f3d25e 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -1186,7 +1186,9 @@ export default defineStack({ method: 'GET', summary: 'Lead feed', type: 'object_operation', - target: 'acme_lead', + // No `target` — an object_operation endpoint is addressed by + // `objectParams` alone; `target` is required (at publish) only for + // `type: 'flow'`, where it names the flow to trigger. objectParams: { object: 'acme_lead', operation: 'find' }, // Defaults to `true`. Omitting it is safe; see the policy table below. authRequired: true, diff --git a/content/docs/references/api/endpoint.mdx b/content/docs/references/api/endpoint.mdx index 12dc8a3adc..fa80cfa515 100644 --- a/content/docs/references/api/endpoint.mdx +++ b/content/docs/references/api/endpoint.mdx @@ -36,7 +36,7 @@ const result = ApiEndpointSchema.parse(data); | **summary** | `string` | optional | | | **description** | `string` | optional | | | **type** | `Enum<'flow' \| 'script' \| 'object_operation' \| 'proxy'>` | ✅ | Implementation type — only 'object_operation' and 'flow' EXECUTE in 17.x. 'script' and 'proxy' stay in the frozen vocabulary (#5040) and are rejected at publish, not parsed and ignored: express script logic as a flow whose script node runs your registered function, and an outbound call as a flow using a declared connector | -| **target** | `string` | ✅ | Target Flow ID or Script Name or Proxy URL, per `type` — but only the Flow ID is reachable in 17.x, since publish rejects `type: 'script'` and `type: 'proxy'` (an `object_operation` endpoint is addressed by `objectParams.object` / `.operation`; neither the publish gate nor the executor reads `target` for that type) | +| **target** | `string` | optional | Target Flow ID, per `type` — REQUIRED at publish for `type: 'flow'` (the gate refuses a flow endpoint that names no target flow) and UNREAD for `type: 'object_operation'`, so do not write it there: that endpoint is addressed by `objectParams.object` / `.operation`, and a `target` beside them is a dead string nothing checks against `objectParams.object` (#10338 made the key optional for exactly that reason). The vocabulary's other spellings — a Script Name or Proxy URL — stay unreachable in 17.x, since publish rejects `type: 'script'` and `type: 'proxy'` | | **objectParams** | `{ object?: string; operation?: Enum<'find' \| 'get' \| 'create' \| 'update' \| 'delete'> }` | optional | For object_operation type | | **inputMapping** | `{ source: string; target: string; transform?: string }[]` | optional | Map Request Body to Internal Params | | **outputMapping** | `{ source: string; target: string; transform?: string }[]` | optional | Map Internal Result to Response Body | diff --git a/examples/app-showcase/src/system/apis/index.ts b/examples/app-showcase/src/system/apis/index.ts index d1a2810258..71b4e88feb 100644 --- a/examples/app-showcase/src/system/apis/index.ts +++ b/examples/app-showcase/src/system/apis/index.ts @@ -71,7 +71,10 @@ export const TaskFeedEndpoint: ApiEndpoint = { summary: 'Task feed', description: 'Returns tasks via a declarative object_operation endpoint — no handler code.', type: 'object_operation', - target: 'showcase_task', + // No `target`: an object_operation endpoint is addressed by `objectParams` + // alone — nothing reads `target` for this type, and #10338 made the key + // optional so an example stops teaching a dead string (`target` is required + // at publish only for `type: 'flow'`, as InquiryPurgeEndpoint below shows). objectParams: { object: 'showcase_task', operation: 'find', diff --git a/examples/app-showcase/test/gap-fill.test.ts b/examples/app-showcase/test/gap-fill.test.ts index 5736386bb2..eb183b47a5 100644 --- a/examples/app-showcase/test/gap-fill.test.ts +++ b/examples/app-showcase/test/gap-fill.test.ts @@ -130,11 +130,14 @@ describe('[#5112] showcase declares its api endpoints again (#5040 E8)', () => { } }); - it('object_operation endpoints target objects that exist', () => { - const apis = (stack as { apis?: Array<{ type: string; target: string }> }).apis ?? []; + it('object_operation endpoints address objects that exist (via objectParams.object — `target` is unread for this type, #10338)', () => { + const apis = (stack as { apis?: Array<{ type: string; objectParams?: { object?: string } }> }).apis ?? []; const objectNames = ((stack as { objects?: Array<{ name: string }> }).objects ?? []).map((o) => o.name); for (const api of apis.filter((a) => a.type === 'object_operation')) { - expect(objectNames, `api endpoint targets missing object '${api.target}'`).toContain(api.target); + // `objectParams.object` is what the executor delegates on; `target` is + // unread for this type and the example no longer writes it. + expect(objectNames, `api endpoint addresses missing object '${api.objectParams?.object}'`) + .toContain(api.objectParams?.object); } }); diff --git a/packages/runtime/src/endpoint-executor.test.ts b/packages/runtime/src/endpoint-executor.test.ts index e14bf55db2..f1d5c70e4b 100644 --- a/packages/runtime/src/endpoint-executor.test.ts +++ b/packages/runtime/src/endpoint-executor.test.ts @@ -529,6 +529,28 @@ describe('an unsupported declaration gets a structured 501, never invented seman expect((await executeEndpointTarget(ctx, deps)).status).toBe(501); expect(execute).not.toHaveBeenCalled(); }); + + // [#10338] `target` is OPTIONAL in the vocabulary now, so a flow endpoint + // with the key OMITTED is a parseable declaration — the publish gate + // refuses it (`apis-publish-gates.test.ts`), and this is the runtime + // counterpart that gate mirrors (`planEndpointTarget`), for a declaration + // that reached the store without passing publish. Pinned as `code` AND + // `status` (ADR-0112): a bare status assertion would stay green on a + // refusal that lost its envelope. + it('a flow that OMITS target answers 501 NOT_IMPLEMENTED — code AND status — and calls nothing', async () => { + const execute = vi.fn(); + const deps = depsWith({ automationService: { execute } }); + const ctx = contextFor(endpoint({ type: 'flow', target: undefined, objectParams: undefined })); + + const answer = await executeEndpointTarget(ctx, deps); + + expect(answer.status).toBe(501); + const error = expectConformantError(answer); + expect(error.code).toBe(DispatcherErrorCode.enum.NOT_IMPLEMENTED); + expect(error.message).toContain('names no target flow'); + expect(execute).not.toHaveBeenCalled(); + expect(deps.callData).not.toHaveBeenCalled(); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/spec/src/api/apis-publish-gates.test.ts b/packages/spec/src/api/apis-publish-gates.test.ts index d578c9a1d4..3c62354f00 100644 --- a/packages/spec/src/api/apis-publish-gates.test.ts +++ b/packages/spec/src/api/apis-publish-gates.test.ts @@ -99,6 +99,21 @@ describe('[#5111] the flip — a well-formed `apis:` publishes', () => { expect(() => defineStack({ manifest, apis: [validObjectEndpoint, validFlowEndpoint] })).not.toThrow(); }); + // [#10338] The acceptance half of the ruling that made `target` optional: + // an `object_operation` endpoint is addressed by `objectParams.object` / + // `.operation`, and NO consumer reads `target` for that type (executor, + // OpenAPI enrichment and this gate all branch on `objectParams` alone) — so + // the author-facing contract stops demanding a dead string. Restoring + // required-ness in the vocabulary turns exactly this case red. + it('accepts an object_operation endpoint that omits `target` — nothing consumes it for that type', () => { + const { target: _dead, ...objectEndpointWithoutTarget } = validObjectEndpoint; + const apis = accept({ manifest, apis: [objectEndpointWithoutTarget] }); + expect(apis).toHaveLength(1); + // Anti-vacuity: the endpoint parses whole, addressed by objectParams. + expect(apis?.[0]?.objectParams).toEqual({ object: 'showcase_task', operation: 'find' }); + expect(apis?.[0]?.target).toBeUndefined(); + }); + it('accepts an anonymous endpoint that arms its rate limit (ADR-0121 D6 satisfied)', () => { const apis = accept({ manifest, @@ -296,6 +311,17 @@ describe('[#5111] gate (a) — the supported subset (mirrors `planEndpointTarget const message = reject({ manifest, apis: [{ ...validFlowEndpoint, target: '' }] }); expect(message).toMatch(/names no target flow/); }); + + // [#10338] `target` is optional in the VOCABULARY (an `object_operation` + // author no longer writes a dead string), so omission now reaches this gate + // instead of dying as a Zod `invalid_type` — and the gate is what holds the + // requirement for `type: 'flow'`. The issue path is asserted too: the author + // must be sent to the `target` line, not to the endpoint at large. + it('rejects a flow endpoint that omits `target` entirely — the gate holds the requirement now', () => { + const { target: _dead, ...flowWithoutTarget } = validFlowEndpoint; + const message = reject({ manifest, apis: [flowWithoutTarget] }); + expect(message).toMatch(/apis\.0\.target: .*names no target flow/); + }); }); describe('[#5111] gate (b) — mapping declarations (mirrors `mappingDeclarationRejection`)', () => { diff --git a/packages/spec/src/api/endpoint.test.ts b/packages/spec/src/api/endpoint.test.ts index 8eb59b10e0..f4d2f28bcd 100644 --- a/packages/spec/src/api/endpoint.test.ts +++ b/packages/spec/src/api/endpoint.test.ts @@ -103,6 +103,25 @@ describe('ApiEndpointSchema', () => { expect(endpoint.name).toBe('get_customers'); }); + // [#10338] `target` is OPTIONAL in the vocabulary: for `object_operation` no + // consumer reads it (the executor, the OpenAPI enrichment and the publish + // gate all branch on `objectParams` alone), so the author is no longer + // forced to write a dead string. The per-type requirement for `type: 'flow'` + // lives in the publish gate (`apis-publish-gates.test.ts` pins it), not + // here — the vocabulary parses both types without the key. + it('parses an object_operation endpoint that omits `target` (#10338)', () => { + const endpoint = ApiEndpointSchema.parse({ + name: 'get_customers', + path: '/api/v1/customers', + method: 'GET', + type: 'object_operation', + objectParams: { object: 'customer', operation: 'find' }, + }); + + expect(endpoint.target).toBeUndefined(); + expect(endpoint.objectParams).toEqual({ object: 'customer', operation: 'find' }); + }); + it('should validate endpoint name format (snake_case)', () => { expect(() => ApiEndpointSchema.parse({ name: 'valid_endpoint_name', diff --git a/packages/spec/src/api/endpoint.zod.ts b/packages/spec/src/api/endpoint.zod.ts index 4a0380728b..c50922291e 100644 --- a/packages/spec/src/api/endpoint.zod.ts +++ b/packages/spec/src/api/endpoint.zod.ts @@ -168,7 +168,7 @@ export const ApiEndpointSchema = strictObject({ /** Execution Logic */ type: z.enum(['flow', 'script', 'object_operation', 'proxy']).describe("Implementation type — only 'object_operation' and 'flow' EXECUTE in 17.x. 'script' and 'proxy' stay in the frozen vocabulary (#5040) and are rejected at publish, not parsed and ignored: express script logic as a flow whose script node runs your registered function, and an outbound call as a flow using a declared connector"), - target: z.string().describe("Target Flow ID or Script Name or Proxy URL, per `type` — but only the Flow ID is reachable in 17.x, since publish rejects `type: 'script'` and `type: 'proxy'` (an `object_operation` endpoint is addressed by `objectParams.object` / `.operation`; neither the publish gate nor the executor reads `target` for that type)"), + target: z.string().optional().describe("Target Flow ID, per `type` — REQUIRED at publish for `type: 'flow'` (the gate refuses a flow endpoint that names no target flow) and UNREAD for `type: 'object_operation'`, so do not write it there: that endpoint is addressed by `objectParams.object` / `.operation`, and a `target` beside them is a dead string nothing checks against `objectParams.object` (#10338 made the key optional for exactly that reason). The vocabulary's other spellings — a Script Name or Proxy URL — stay unreachable in 17.x, since publish rejects `type: 'script'` and `type: 'proxy'`"), /** Logic Config */ objectParams: z.object({ diff --git a/packages/spec/src/kernel/metadata-type-api-registration.test.ts b/packages/spec/src/kernel/metadata-type-api-registration.test.ts index 0833977087..57325c4c41 100644 --- a/packages/spec/src/kernel/metadata-type-api-registration.test.ts +++ b/packages/spec/src/kernel/metadata-type-api-registration.test.ts @@ -189,17 +189,39 @@ describe('the declaration shape door (artifact / publish route — see the heade } }); - it('refuses a body missing `target` — loudly, naming the key', () => { - // The shape `PUT /meta/api/:name` used to store with a 200: no execution - // target at all, so nothing could ever run it. + it('a headless body parses at the shape door and is refused by the GATE, naming the missing half (#10338)', () => { + // Until #10338 the VOCABULARY refused this body for its missing `target`. + // That requirement was a dead letter for `object_operation` — nothing + // reads `target` for that type — so the key is optional now, and "no + // execution target at all, nothing could ever run it" is judged where the + // real execution address lives: the publish gate on `objectParams` (and, + // for `type: 'flow'`, on `target`). The refusal is still loud and still + // names the key; it just names the key that matters. const parsed = ApiEndpointSchema.safeParse({ name: 'headless_endpoint', path: '/api/v1/apps/showcase/x', method: 'GET', type: 'object_operation', }); - expect(parsed.success).toBe(false); - expect(parsed.error!.issues.map((i) => i.path.join('.'))).toContain('target'); + expect(parsed.success, 'the SHAPE is fine — only servability is not').toBe(true); + + const failure = identityFreeEndpointGateFailure(parsed.data!); + expect(failure, 'the gate must refuse an endpoint with no execution address').toBeDefined(); + expect(failure!.path).toEqual(['objectParams']); + + // The flow-typed spelling of the same headlessness is refused at `target`, + // where the flow's execution address lives. + const flowParsed = ApiEndpointSchema.safeParse({ + name: 'headless_flow_endpoint', + path: '/api/v1/apps/showcase/y', + method: 'POST', + type: 'flow', + }); + expect(flowParsed.success).toBe(true); + const flowFailure = identityFreeEndpointGateFailure(flowParsed.data!); + expect(flowFailure).toBeDefined(); + expect(flowFailure!.path).toEqual(['target']); + expect(flowFailure!.message).toMatch(/names no target flow/); }); it('refuses a body that is not an endpoint at all', () => {