Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/lazy-pugs-shake.md
Original file line numberDiff line numberDiff line change
@@ -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.
19 changes: 11 additions & 8 deletions content/docs/api/declarative-endpoints.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
3 changes: 2 additions & 1 deletion content/docs/getting-started/quick-reference.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -324,7 +324,8 @@ export const leadFeed: ApiEndpoint = {
path: '/api/v1/apps/acme/leads', // /api/v1/apps/<namespace>/<subpath>
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,
Expand Down
4 changes: 3 additions & 1 deletion content/docs/protocol/kernel/http-protocol.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/endpoint.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |
Expand Down
5 changes: 4 additions & 1 deletion examples/app-showcase/src/system/apis/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
9 changes: 6 additions & 3 deletions examples/app-showcase/test/gap-fill.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}
});

Expand Down
22 changes: 22 additions & 0 deletions packages/runtime/src/endpoint-executor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
});
});

// ---------------------------------------------------------------------------
Expand Down
26 changes: 26 additions & 0 deletions packages/spec/src/api/apis-publish-gates.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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`)', () => {
Expand Down
19 changes: 19 additions & 0 deletions packages/spec/src/api/endpoint.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/api/endpoint.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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({
Expand Down
32 changes: 27 additions & 5 deletions packages/spec/src/kernel/metadata-type-api-registration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand Down
Loading