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
61 changes: 61 additions & 0 deletions .changeset/declarative-endpoints-docs-skill-catchup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
"@objectstack/spec": patch
---

docs(spec,skills): the docs and the `objectstack-api` skill catch up with the endpoint executor (#5238)

The executor landed (#5040 E1–E8) and the blanket refusal of a non-empty `apis:`
became five per-endpoint publish gates — but three prose surfaces still told the
reader the opposite. That is worse than stale: each one is an *instruction* an
upgrading author (very often an AI maintainer with nothing but that text) would
follow away from a capability that now works.

**Two prescription texts in `packages/spec` — the shipped half of this change.**

- `App.apis`'s `retiredKey()` tombstone said the stack-level `defineStack({ apis })`
it redirects to "is ALSO not executable in v17 (#4936) … until the endpoint
executor ships". A tombstone that redirects somewhere has to be true about the
place it points at; this one sent the author to a surface it called dead in the
same sentence, so the natural next move was to keep serving the route from
handler code. It now says the surface EXECUTES from protocol 17, names the five
gates, and carries the two things to get right when moving a declaration up a
level: the `/api/v1/apps/<namespace>/<subpath>` carve-out with an explicit
`manifest.namespace` (ADR-0121 D1/D2), and `authRequired` defaulting to `true`
with ADR-0121 D6's armed-`rateLimit` pairing on an explicit `false`. The removal
half is untouched — `App.apis` was never read and is still gone — and #4936 is
still named, because the history is why the redirect exists at all.
- The `defineStack({ server })` module header said #5040 "wires endpoint-level
`rateLimit` — still unwired today". It is wired. The header now also states the
relationship an author of the server-level budget actually needs: endpoint
buckets are keyed in their own namespace, so the two budgets meter
independently rather than sharing a counter.

Both feed `content/docs/references/` through `gen:docs`; those two pages move with
them and nothing else does.

**Hand-written docs.** `protocol/kernel/http-protocol.mdx` traded its "that surface
has no executor" callout for a real **Declarative Endpoints** section: the serving
chain (match → policy chain → delegation to the same pipelines the built-in routes
use), the five gates, the policy answers (401 / 429 + `Retry-After` /
`Cache-Control: private, max-age=<ttl>` on successes only), and the identity that is
easy to get wrong — an unmatched path, and a **method mismatch on a declared path**,
both keep the transport's bare `404` byte for byte, because the seam is Hono's
`notFound` and not a registered route, so there is no method set to report a `405`
over. `getting-started/quick-reference.mdx` gains the compact lookup entry.

**The `objectstack-api` skill** stopped describing `ApiEndpointSchema` as a
four-arm `type` union with a `target` and gained a section that teaches the current
capability: when `apis:` beats `contributes.routes` (and when it does not — real
handler code), the carve-out, the gates as *things to run `objectstack validate`
for* rather than texts to memorise, D6 with its `enabled === true` predicate spelled
out, and the mapping keys' minimal semantics (projection by dot path; no
`transform`; no `inputMapping` on a bodyless operation). It points at the
`declarative-apis-endpoints-live` upgrade entry rather than restating the review,
so there is one source of truth for it.

Every claim above was measured against the built spec rather than believed: the
doc example publishes, the `authRequired`-omitted shape resolves to `true`, and the
`rateLimit` written without `enabled: true` beside `authRequired: false` is refused
with its prescription. The tombstone's new text is pinned two-sided (it must say
the new thing AND must not say the retired one, with `#4936` still present) so a
regression cannot pass by emptying the message.
32 changes: 32 additions & 0 deletions content/docs/getting-started/quick-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,38 @@ import type { Field, ServiceObject, QueryAST } from '@objectstack/spec/data';
import type { View } from '@objectstack/spec/ui';
```

### Declarative Endpoints (`apis:`)

Declared on the **stack** (`defineStack({ apis })`), live from protocol 17 — each entry
is gated at publish and, once it passes, serves real traffic. Full contract:
[HTTP API → Declarative Endpoints](/docs/protocol/kernel/http-protocol).

| Rule | Value |
|:---|:---|
| **Path shape** | `/api/v1/apps/<manifest.namespace>/<subpath>` — only the subpath is yours (ADR-0121 D1) |
| **Namespace** | must be declared **explicitly** as `manifest.namespace`; never derived from `manifest.id` (D2) |
| **Types that execute** | `object_operation` (needs `objectParams.object` + `.operation`) and `flow` (needs `target`). `script` / `proxy` are rejected at publish |
| **`authRequired`** | defaults to `true` — **omitting it is safe**. An explicit `false` is the only thing that opens anonymous access |
| **`authRequired: false`** | REQUIRES an armed budget, `rateLimit: { enabled: true, windowMs, maxRequests }` (ADR-0121 D6) — `enabled` itself defaults to `false`, so a budget without it meters nothing |
| **`cacheTtl`** | seconds, GET-only, applied to successful answers only (`Cache-Control: private, max-age=<ttl>`) |

{/* os:check */}
```typescript
import type { ApiEndpointInput } from '@objectstack/spec/api';

// With `manifest: { namespace: 'acme', … }` on the same stack.
export const leadFeed: ApiEndpointInput = {
name: 'acme_lead_feed',
path: '/api/v1/apps/acme/leads', // /api/v1/apps/<namespace>/<subpath>
method: 'GET',
type: 'object_operation',
target: 'acme_lead',
objectParams: { object: 'acme_lead', operation: 'find' },
// `authRequired` omitted → defaults to true (a session is required).
cacheTtl: 30,
};
```

## Search Tips

- **By Feature**: "How do I model relationships?" → [Field Protocol](/docs/references/data/field) → `lookup` or `master_detail` types
Expand Down
137 changes: 130 additions & 7 deletions content/docs/protocol/kernel/http-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1036,17 +1036,140 @@ is the declared budget multiplied by the number of nodes.
<Callout type="warn">
**Not implemented, deliberately named rather than implied.** ObjectStack does **not**
emit `X-RateLimit-Limit` / `-Remaining` / `-Reset` headers on successful responses —
only `Retry-After` on a 429. And the per-endpoint `rateLimit` key on
`ApiEndpointSchema` is **not wired to anything**; declaring it changes nothing today.
It travels with the declarative `apis:` surface as a whole: since
[#4936](https://github.com/objectstack-ai/objectstack/issues/4936) that surface has no
executor and a **non-empty `apis:` is rejected at publish/validate**, so the key cannot
be reached at all. Wiring it is part of the endpoint executor, tracked by
[#5040](https://github.com/objectstack-ai/objectstack/issues/5040).
only `Retry-After` on a 429. That is still true of every budget on the platform,
including the per-endpoint one below.
(The second spelling this callout used to name, `ApiEndpointRegistrationSchema`, was
retired outright in [#4939](https://github.com/objectstack-ai/objectstack/issues/4939).)
</Callout>

The **per-endpoint** `rateLimit` on `ApiEndpointSchema` is a second, independent budget.
Between [#4936](https://github.com/objectstack-ai/objectstack/issues/4936) and protocol
17 it was unreachable — that surface had no executor and a non-empty `apis:` was
rejected outright — but the executor shipped with
[#5040](https://github.com/objectstack-ai/objectstack/issues/5040) and the key is now
enforced. Endpoint buckets are keyed in their own namespace, so an endpoint budget and
the `server.security.rateLimit` budget above meter separately rather than sharing a
counter. See the next section.

## Declarative Endpoints (`apis:`)

A stack can declare an HTTP endpoint as **metadata** instead of writing a handler:

<Callout type="warn">
**Declared endpoints are LIVE from protocol 17.** An `apis:` block written against an
older major changes meaning without changing a byte — what used to be inert
documentation becomes an execution entry point. Before upgrading, work through the
`declarative-apis-endpoints-live` entry of the protocol upgrade guide; it is a security
review, not a rename.
</Callout>

{/* os:check */}
```typescript
import { defineStack } from '@objectstack/spec';

export default defineStack({
manifest: {
id: 'acme-crm',
name: 'Acme CRM',
version: '1.0.0',
type: 'app',
// REQUIRED to declare `apis:` — the URL carve-out is derived from it, and
// there is deliberately no fallback that derives it from `manifest.id`.
namespace: 'acme',
},
apis: [
{
name: 'acme_lead_feed',
// `/api/v1/apps/<manifest.namespace>/<subpath>` — only the subpath is yours.
path: '/api/v1/apps/acme/leads',
method: 'GET',
summary: 'Lead feed',
type: 'object_operation',
target: 'acme_lead',
objectParams: { object: 'acme_lead', operation: 'find' },
// Defaults to `true`. Omitting it is safe; see the policy table below.
authRequired: true,
// Seconds. GET-only, and only ever on a successful answer.
cacheTtl: 30,
},
],
});
```

### How a request is served

Declared endpoints are **not registered routes**. They run in the dispatcher's
unmatched-request seam, which is what makes it structurally impossible for a
declaration to shadow a built-in route:

1. **Match** — the request path must be under `<prefix>/apps/`, and `METHOD` + path
(one trailing slash trimmed) must hit exactly one declaration.
2. **Policy chain** — `rateLimit` → `authRequired` → `cacheTtl`, in that order. Metering
runs *before* the auth gate on purpose: the traffic that most needs a budget
(credential stuffing, scraping) is exactly the traffic that ends in a 401, so a denied
request still spends a token.
3. **Delegation** — a request that passed is executed by the **same pipelines the
built-in routes use**, under the caller's own execution context, so RLS/FLS and the
exposure gate apply identically. A declared endpoint is a stable URL plus a policy
layer over an existing pipeline, never a second execution dialect.

| Endpoint declares | Answer |
|:---|:---|
| `type: 'object_operation'` | delegated to the same `callData` binding that serves `/api/v1/data/{object}` — byte-identical `data` |
| `type: 'flow'` | delegated to the same automation pipeline as `POST /api/v1/automation/{name}/trigger` |
| `authRequired: true` (or omitted) + anonymous caller | `401` `UNAUTHENTICATED`, the same envelope every seam answers |
| `rateLimit` armed and exhausted | `429` + `Retry-After`, never with a cache directive |
| `cacheTtl: 30` on a successful GET | `Cache-Control: private, max-age=30` — `private` is a security rule, not tuning: any response can be RLS-trimmed |
| `cacheTtl: 0` | `Cache-Control: no-store` |
| an error answer (401/429/5xx) | never carries `Cache-Control`, and `outputMapping` is never applied to it |

### What an unmatched request answers

The endpoint seam **writes nothing** when it does not match, so it changes no existing
answer. Both of these are the transport's own bare 404, byte for byte:

```http
GET /api/v1/apps/acme/no-such-endpoint → 404 {"error":"Not found"}
GET /api/v1/no-such-route → 404 {"error":"Not found"}
```

A **method mismatch on a declared path is also a 404**, not `405` + `Allow`:

```http
POST /api/v1/apps/acme/leads → 404 {"error":"Not found"}
```

That is a consequence of the seam, not an inconsistency — nothing registered a route for
that path, so there is no method set to report. A registered route still answers `405`
when it exists and the verb does not fit — that contract is unchanged.

### The five publish gates

A declaration this runtime cannot serve is **rejected at publish**, naming the endpoint,
the key and the fix — never parsed into silence. `objectstack validate` (or `os build`)
runs the same gates your publish path does:

| Gate | Rejects |
|:---|:---|
| **Namespace** (ADR-0121 D1/D2) | a `path` outside `/api/v1/apps/<manifest.namespace>/<subpath>`, or a stack declaring `apis:` with no explicit `manifest.namespace` |
| **Supported target** | `type: 'script'` / `'proxy'` (neither executes in 17.x), an `object_operation` missing `objectParams.object` or `.operation`, a `flow` naming no `target` |
| **Mapping** | a mapping `transform` (there is no transformation registry), an unusable dot path (empty segment, `__proto__`), two entries writing the same target path, or `inputMapping` on a `find` / `get` / `delete` operation that never reads a body |
| **Policy** | `authRequired: false` without `rateLimit.enabled: true` (ADR-0121 D6), an unusable armed budget, a negative `cacheTtl`, or `cacheTtl` on a non-GET method |
| **Uniqueness** | two endpoints in one stack claiming the same `METHOD` + path |

<Callout type="warn">
**`authRequired: false` is the one answer that cannot be taken back.** It defaults to
`true`, so omitting it is safe; an explicit `false` is the only thing that opens an
unauthenticated execution entry point, and ADR-0121 D6 pairs it with an **armed** budget
— `rateLimit.enabled` itself defaults to `false`, so writing only `windowMs` /
`maxRequests` declares a budget that meters nothing. The gate checks
`enabled === true`, not the key's presence.
</Callout>

`inputMapping` / `outputMapping` **move and rename fields by dot path, and nothing more**
— `inputMapping` projects the request body before delegation (so it can never buy a
caller past the policy chain), `outputMapping` projects a successful response body only.

## Best Practices

### Use Field Selection
Expand Down
20 changes: 16 additions & 4 deletions content/docs/references/system/stack-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,25 @@ baked into the artifact.

Related: #4910 (this seam), #4937 (the limiter that documented an execution

chain it never had), #4936 (the declarative `apis:` surface: vocabulary kept,
chain it never had), #4936 (the declarative `apis:` surface as it stood while

a non-empty array rejected until an executor exists) and #5040 (that
nothing executed it: vocabulary kept, a non-empty array rejected outright) and

executor, which wires endpoint-level `rateLimit` — still unwired today),
#5040 — the executor that ended that state. From protocol 17 a declared

ADR-0069 D2 (shared counters), ADR-0049 (enforce or remove).
endpoint is LIVE behind five per-endpoint publish gates, and its own

`rateLimit` is enforced by the endpoint policy chain against a bucket keyed in

a separate namespace, so an endpoint budget and the server-level budget

declared here meter INDEPENDENTLY rather than sharing a counter. The upgrade

checklist for that flip is the `declarative-apis-endpoints-live` entry of the

protocol upgrade guide. ADR-0069 D2 (shared counters), ADR-0049 (enforce or

remove).

<Callout type="info">
**Source:** `packages/spec/src/system/stack-server.zod.ts`
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/ui/app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const result = ActionNavItemSchema.parse(data);
| **homePageId** | `any` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, #4709, ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
| **requiredPermissions** | `string[]` | optional | Permissions required to access this app |
| **objects** | `any` | optional | [REMOVED] `App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read; the spec itself labelled it "config file convenience"). Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. Delete the key. |
| **apis** | `any` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Delete the key. Note the stack-level `defineStack({ apis })` this prescription used to redirect to is ALSO not executable in v17 (#4936): the vocabulary is kept but a non-empty array is rejected there too, until the endpoint executor ships (tracked by https://github.com/objectstack-ai/objectstack/issues/5040). Serve the route in code meanwhile — a plugin manifest `contributes.routes` entry or an `http.server` route. |
| **apis** | `any` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Delete the key and declare the endpoint one level up, on the STACK: `defineStack({ apis })`. That surface EXECUTES from protocol 17 (#5040). Between #4936 and the executor landing it was refused wholesale — nothing mounted a declared path, so every key including `authRequired` parsed and gated nothing — and that blanket refusal is now narrowed to five per-endpoint publish gates (namespace, supported target, mapping, policy, uniqueness): an endpoint that passes them is mounted and serves traffic as soon as the stack is published. Two things to get right when you move it: the path must sit inside your own carve-out, `/api/v1/apps/<manifest.namespace>/<subpath>` with an explicit `manifest.namespace` (ADR-0121 D1/D2), and `authRequired` defaults to `true` — an explicit `false` is the only thing that opens anonymous access, and ADR-0121 D6 then requires an armed `rateLimit: { enabled: true, windowMs, maxRequests }`. Read the `declarative-apis-endpoints-live` entry of the protocol upgrade guide first; it is a security review, not a rename. A route that genuinely needs handler CODE still belongs in a plugin manifest `contributes.routes` entry. |
| **sharing** | `any` | optional | [REMOVED] `App.sharing` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049 enforce-or-remove) — no public-app route ever read it, so it declared sharing that did not exist. Public access is granted per FORM VIEW (`FormView.sharing`, the public-data-collection surface). Delete the key. |
| **embed** | `any` | optional | [REMOVED] `App.embed` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049) — no iframe route ever read it. Embedding is a per-form-view surface (`FormView.sharing`), not an app-level switch. Delete the key. |
| **mobileNavigation** | `any` | optional | [REMOVED] `App.mobileNavigation` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — fully unimplemented; no renderer, including packages/mobile, ever read it). Delete the key; the block returns if/when a real mobile navigation ships. |
Expand Down
14 changes: 10 additions & 4 deletions packages/spec/src/system/stack-server.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,16 @@
* baked into the artifact.
*
* Related: #4910 (this seam), #4937 (the limiter that documented an execution
* chain it never had), #4936 (the declarative `apis:` surface: vocabulary kept,
* a non-empty array rejected until an executor exists) and #5040 (that
* executor, which wires endpoint-level `rateLimit` — still unwired today),
* ADR-0069 D2 (shared counters), ADR-0049 (enforce or remove).
* chain it never had), #4936 (the declarative `apis:` surface as it stood while
* nothing executed it: vocabulary kept, a non-empty array rejected outright) and
* #5040 — the executor that ended that state. From protocol 17 a declared
* endpoint is LIVE behind five per-endpoint publish gates, and its own
* `rateLimit` is enforced by the endpoint policy chain against a bucket keyed in
* a separate namespace, so an endpoint budget and the server-level budget
* declared here meter INDEPENDENTLY rather than sharing a counter. The upgrade
* checklist for that flip is the `declarative-apis-endpoints-live` entry of the
* protocol upgrade guide. ADR-0069 D2 (shared counters), ADR-0049 (enforce or
* remove).
*/

import { z } from 'zod';
Expand Down
Loading
Loading