Skip to content

Commit d2619fd

Browse files
claude[bot]claude
andauthored
spec(api): make ApiEndpoint.target optional; publish gate holds the flow requirement (#11290)
* spec(api): make ApiEndpoint.target optional; gate holds the flow requirement (#10338) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RadETjNRLALFLhFA3xehZP * test(spec): replace old required-target pin with gate-level pins; changeset (#10338) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RadETjNRLALFLhFA3xehZP --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 376c70f commit d2619fd

12 files changed

Lines changed: 136 additions & 21 deletions

File tree

.changeset/lazy-pugs-shake.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`ApiEndpoint.target` is now **optional** in the vocabulary (#10338, maintainer ruling
6+
2026-08-23). The key was required on every endpoint but read only for `type: 'flow'`
7+
(executor, OpenAPI enrichment and the publish gate all address an `object_operation`
8+
via `objectParams.object` / `.operation`) — so an `object_operation` author was forced
9+
to write a dead string nothing consumed or cross-checked. Authoring guidance: omit
10+
`target` on `object_operation` endpoints. The publish gate still **requires** `target`
11+
for `type: 'flow'` — a flow endpoint that names no target flow is refused at publish,
12+
and the runtime's structural backstop answers `501 NOT_IMPLEMENTED` for one that
13+
reached the store another way. No migration: this is a pure widening — every previously
14+
valid declaration (all of which carry a string `target`) still parses unchanged.

content/docs/api/declarative-endpoints.mdx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,10 @@ export default defineStack({
6868
summary: 'Lead feed',
6969
description: 'Open leads, for the partner portal.',
7070
type: 'object_operation',
71-
// `target` is required on every entry by the vocabulary. An
72-
// object_operation is addressed by `objectParams`, so nothing reads this
73-
// one — name the object it works on and keep the two in step.
74-
target: 'acme_lead',
71+
// No `target` — an object_operation is addressed by `objectParams`
72+
// alone, so the key is unread for this type and omitting it is the
73+
// correct spelling. `target` is required (at publish) only for
74+
// `type: 'flow'`, as `acme_lead_intake` below shows.
7575
objectParams: { object: 'acme_lead', operation: 'find' },
7676
// Omitting `authRequired` is the safe spelling — it defaults to `true`.
7777
cacheTtl: 30,
@@ -149,10 +149,13 @@ under that mount.
149149
| `type: 'flow'` with no `target` | rejected |
150150
| `type: 'script'`, `type: 'proxy'` | rejected at publish |
151151

152-
`target` is required on **every** entry, whatever the `type`. A `flow` endpoint is executed
153-
by it; an `object_operation` is addressed by `objectParams` instead, so nothing reads its
154-
`target` — write the object name there and keep the two in step, because leaving the key
155-
out is a type error before it is anything else.
152+
`target` is **per-type**: a `flow` endpoint is executed by it, so publish requires it
153+
there and refuses a flow that names no target flow. An `object_operation` is addressed by
154+
`objectParams` instead — nothing reads its `target`, and the key is optional in the
155+
vocabulary precisely so you can leave it out. Do: omit it on `object_operation` entries.
156+
A `target` written there is a dead string nothing checks against `objectParams.object`
157+
a declaration whose first line says one object while `objectParams` serves another
158+
publishes green, which is why the dead spelling is not worth teaching.
156159

157160
`script` and `proxy` are **not servable declarations**. Nothing in the platform verifies
158161
that a script target is reachable, and forwarding to an arbitrary outbound URL is an

content/docs/getting-started/quick-reference.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,8 @@ export const leadFeed: ApiEndpoint = {
324324
path: '/api/v1/apps/acme/leads', // /api/v1/apps/<namespace>/<subpath>
325325
method: 'GET',
326326
type: 'object_operation',
327-
target: 'acme_lead',
327+
// No `target` — object_operation is addressed by `objectParams` alone;
328+
// `target` is required (at publish) only for `type: 'flow'`.
328329
objectParams: { object: 'acme_lead', operation: 'find' },
329330
// `authRequired` omitted → defaults to true (a session is required).
330331
cacheTtl: 30,

content/docs/protocol/kernel/http-protocol.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1186,7 +1186,9 @@ export default defineStack({
11861186
method: 'GET',
11871187
summary: 'Lead feed',
11881188
type: 'object_operation',
1189-
target: 'acme_lead',
1189+
// No `target` — an object_operation endpoint is addressed by
1190+
// `objectParams` alone; `target` is required (at publish) only for
1191+
// `type: 'flow'`, where it names the flow to trigger.
11901192
objectParams: { object: 'acme_lead', operation: 'find' },
11911193
// Defaults to `true`. Omitting it is safe; see the policy table below.
11921194
authRequired: true,

content/docs/references/api/endpoint.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const result = ApiEndpointSchema.parse(data);
3636
| **summary** | `string` | optional | |
3737
| **description** | `string` | optional | |
3838
| **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 |
39-
| **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) |
39+
| **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'` |
4040
| **objectParams** | `{ object?: string; operation?: Enum<'find' \| 'get' \| 'create' \| 'update' \| 'delete'> }` | optional | For object_operation type |
4141
| **inputMapping** | `{ source: string; target: string; transform?: string }[]` | optional | Map Request Body to Internal Params |
4242
| **outputMapping** | `{ source: string; target: string; transform?: string }[]` | optional | Map Internal Result to Response Body |

examples/app-showcase/src/system/apis/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ export const TaskFeedEndpoint: ApiEndpoint = {
7171
summary: 'Task feed',
7272
description: 'Returns tasks via a declarative object_operation endpoint — no handler code.',
7373
type: 'object_operation',
74-
target: 'showcase_task',
74+
// No `target`: an object_operation endpoint is addressed by `objectParams`
75+
// alone — nothing reads `target` for this type, and #10338 made the key
76+
// optional so an example stops teaching a dead string (`target` is required
77+
// at publish only for `type: 'flow'`, as InquiryPurgeEndpoint below shows).
7578
objectParams: {
7679
object: 'showcase_task',
7780
operation: 'find',

examples/app-showcase/test/gap-fill.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,14 @@ describe('[#5112] showcase declares its api endpoints again (#5040 E8)', () => {
130130
}
131131
});
132132

133-
it('object_operation endpoints target objects that exist', () => {
134-
const apis = (stack as { apis?: Array<{ type: string; target: string }> }).apis ?? [];
133+
it('object_operation endpoints address objects that exist (via objectParams.object — `target` is unread for this type, #10338)', () => {
134+
const apis = (stack as { apis?: Array<{ type: string; objectParams?: { object?: string } }> }).apis ?? [];
135135
const objectNames = ((stack as { objects?: Array<{ name: string }> }).objects ?? []).map((o) => o.name);
136136
for (const api of apis.filter((a) => a.type === 'object_operation')) {
137-
expect(objectNames, `api endpoint targets missing object '${api.target}'`).toContain(api.target);
137+
// `objectParams.object` is what the executor delegates on; `target` is
138+
// unread for this type and the example no longer writes it.
139+
expect(objectNames, `api endpoint addresses missing object '${api.objectParams?.object}'`)
140+
.toContain(api.objectParams?.object);
138141
}
139142
});
140143

packages/runtime/src/endpoint-executor.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,28 @@ describe('an unsupported declaration gets a structured 501, never invented seman
529529
expect((await executeEndpointTarget(ctx, deps)).status).toBe(501);
530530
expect(execute).not.toHaveBeenCalled();
531531
});
532+
533+
// [#10338] `target` is OPTIONAL in the vocabulary now, so a flow endpoint
534+
// with the key OMITTED is a parseable declaration — the publish gate
535+
// refuses it (`apis-publish-gates.test.ts`), and this is the runtime
536+
// counterpart that gate mirrors (`planEndpointTarget`), for a declaration
537+
// that reached the store without passing publish. Pinned as `code` AND
538+
// `status` (ADR-0112): a bare status assertion would stay green on a
539+
// refusal that lost its envelope.
540+
it('a flow that OMITS target answers 501 NOT_IMPLEMENTED — code AND status — and calls nothing', async () => {
541+
const execute = vi.fn();
542+
const deps = depsWith({ automationService: { execute } });
543+
const ctx = contextFor(endpoint({ type: 'flow', target: undefined, objectParams: undefined }));
544+
545+
const answer = await executeEndpointTarget(ctx, deps);
546+
547+
expect(answer.status).toBe(501);
548+
const error = expectConformantError(answer);
549+
expect(error.code).toBe(DispatcherErrorCode.enum.NOT_IMPLEMENTED);
550+
expect(error.message).toContain('names no target flow');
551+
expect(execute).not.toHaveBeenCalled();
552+
expect(deps.callData).not.toHaveBeenCalled();
553+
});
532554
});
533555

534556
// ---------------------------------------------------------------------------

packages/spec/src/api/apis-publish-gates.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,21 @@ describe('[#5111] the flip — a well-formed `apis:` publishes', () => {
9999
expect(() => defineStack({ manifest, apis: [validObjectEndpoint, validFlowEndpoint] })).not.toThrow();
100100
});
101101

102+
// [#10338] The acceptance half of the ruling that made `target` optional:
103+
// an `object_operation` endpoint is addressed by `objectParams.object` /
104+
// `.operation`, and NO consumer reads `target` for that type (executor,
105+
// OpenAPI enrichment and this gate all branch on `objectParams` alone) — so
106+
// the author-facing contract stops demanding a dead string. Restoring
107+
// required-ness in the vocabulary turns exactly this case red.
108+
it('accepts an object_operation endpoint that omits `target` — nothing consumes it for that type', () => {
109+
const { target: _dead, ...objectEndpointWithoutTarget } = validObjectEndpoint;
110+
const apis = accept({ manifest, apis: [objectEndpointWithoutTarget] });
111+
expect(apis).toHaveLength(1);
112+
// Anti-vacuity: the endpoint parses whole, addressed by objectParams.
113+
expect(apis?.[0]?.objectParams).toEqual({ object: 'showcase_task', operation: 'find' });
114+
expect(apis?.[0]?.target).toBeUndefined();
115+
});
116+
102117
it('accepts an anonymous endpoint that arms its rate limit (ADR-0121 D6 satisfied)', () => {
103118
const apis = accept({
104119
manifest,
@@ -296,6 +311,17 @@ describe('[#5111] gate (a) — the supported subset (mirrors `planEndpointTarget
296311
const message = reject({ manifest, apis: [{ ...validFlowEndpoint, target: '' }] });
297312
expect(message).toMatch(/names no target flow/);
298313
});
314+
315+
// [#10338] `target` is optional in the VOCABULARY (an `object_operation`
316+
// author no longer writes a dead string), so omission now reaches this gate
317+
// instead of dying as a Zod `invalid_type` — and the gate is what holds the
318+
// requirement for `type: 'flow'`. The issue path is asserted too: the author
319+
// must be sent to the `target` line, not to the endpoint at large.
320+
it('rejects a flow endpoint that omits `target` entirely — the gate holds the requirement now', () => {
321+
const { target: _dead, ...flowWithoutTarget } = validFlowEndpoint;
322+
const message = reject({ manifest, apis: [flowWithoutTarget] });
323+
expect(message).toMatch(/apis\.0\.target: .*names no target flow/);
324+
});
299325
});
300326

301327
describe('[#5111] gate (b) — mapping declarations (mirrors `mappingDeclarationRejection`)', () => {

packages/spec/src/api/endpoint.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,25 @@ describe('ApiEndpointSchema', () => {
103103
expect(endpoint.name).toBe('get_customers');
104104
});
105105

106+
// [#10338] `target` is OPTIONAL in the vocabulary: for `object_operation` no
107+
// consumer reads it (the executor, the OpenAPI enrichment and the publish
108+
// gate all branch on `objectParams` alone), so the author is no longer
109+
// forced to write a dead string. The per-type requirement for `type: 'flow'`
110+
// lives in the publish gate (`apis-publish-gates.test.ts` pins it), not
111+
// here — the vocabulary parses both types without the key.
112+
it('parses an object_operation endpoint that omits `target` (#10338)', () => {
113+
const endpoint = ApiEndpointSchema.parse({
114+
name: 'get_customers',
115+
path: '/api/v1/customers',
116+
method: 'GET',
117+
type: 'object_operation',
118+
objectParams: { object: 'customer', operation: 'find' },
119+
});
120+
121+
expect(endpoint.target).toBeUndefined();
122+
expect(endpoint.objectParams).toEqual({ object: 'customer', operation: 'find' });
123+
});
124+
106125
it('should validate endpoint name format (snake_case)', () => {
107126
expect(() => ApiEndpointSchema.parse({
108127
name: 'valid_endpoint_name',

0 commit comments

Comments
 (0)