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
47 changes: 47 additions & 0 deletions .changeset/discovery-surface-schema-authoritative.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@objectstack/spec": minor
"@objectstack/metadata-protocol": minor
"@objectstack/runtime": minor
---

feat(spec,runtime,metadata-protocol)!: one schema for both discovery producers — `capabilities` canonical, `features`/`endpoints` retired, `scoping` declared (#4828)

`/discovery` is a machine-readable surface, but nothing compared what the two
producers emit against what `packages/spec` declares. The only schema the
protocol layer referenced was `GetDiscoveryResponseSchema` —
`DiscoverySchema.partial().required({version}).extend({apiName})` — so
`.partial()` hid every missing REQUIRED key while zod's default unknown-key
strip hid every UNDECLARED emitted one. The two producers then drifted in
opposite directions through the same blind spot.

`DiscoverySchema` is now authoritative for producers, and each producer package
carries a `discovery-schema-conformance.test.ts` that parses its LIVE shape
against it and checks its emitted key set against the protocol schema's shape.

**Breaking for anyone reading the dispatcher's `/.well-known/objectstack` body:**

- `features` → **`capabilities`**, the name `DiscoverySchema` has always
declared, in the declared `{ enabled }` shape. The same flags survive. This
fixes a real defect: the SDK's `client.capabilities` getter reads
`discoveryInfo.capabilities`, so against a dispatcher-served host it returned
`undefined` for every flag while the answers sat one key away under `features`.
- `endpoints` — **removed**. It duplicated `routes` verbatim as a
"backward compatibility" alias; a consumer census across `objectstack`,
`objectui` and `cloud` found no reader. Use `routes`.
- `environment` is now **mapped** into its declared enum instead of passing
`NODE_ENV` through raw (`test` → `development`, `staging` → `sandbox`,
unrecognized → `development`, never `production` on a guess). `NODE_ENV=test`
and `staging` previously advertised values outside the declared enum.

**Additive elsewhere:**

- `DiscoverySchema` declares `scoping` (optional) — the environment-scoping
posture the REST endpoint has always emitted and `packages/client` has always
consumed, now part of the contract instead of an undeclared extra.
- The REST `/discovery` body gains the required `name` / `environment` /
`locale`, so it can satisfy `DiscoverySchema` at all. `locale` is derived from
the registered i18n service, the same way the dispatcher derives it.
- `name` is canonical on both producers. `apiName` remains as a deprecated alias
carrying the identical value and is **scheduled for removal in protocol 18**.
- New exports: `DiscoveryEnvironmentSchema`, `DiscoveryEnvironment`,
`resolveDiscoveryEnvironment`.
88 changes: 62 additions & 26 deletions content/docs/protocol/kernel/http-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ The **HTTP API** defines how ObjectStack maps data operations to RESTful HTTP en

Before making any API calls, clients should request a discovery endpoint to learn about
available services. **Two endpoints answer that question, and in a stack that mounts
`@objectstack/rest` they do not return the same shape** — they are built by different
packages. Read the one that matches your composition; do not mix their fields.
`@objectstack/rest` they are built by different packages** — so they carry different
values (and different envelopes), even though both now satisfy the same `DiscoverySchema`
(#4828). Read the one that matches your composition.

### `GET /api/v1` (and `GET /api/v1/discovery`)

Expand All @@ -43,11 +44,18 @@ Host: api.acme.com
```json
{
"version": "v1",
"name": "ObjectStack API",
"apiName": "ObjectStack API",
"environment": "development",
"routes": {
"data": "/api/v1/data",
"metadata": "/api/v1/meta"
},
"locale": {
"default": "en",
"supported": ["en"],
"timezone": "UTC"
},
"services": {
"metadata": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/meta", "provider": "objectql" },
"data": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/data", "provider": "objectql" },
Expand All @@ -68,24 +76,32 @@ Host: api.acme.com
}
```

Three things about this body are worth stating explicitly, because they are what the
`/.well-known/objectstack` document below does *not* share:
Three things about this body are worth stating explicitly:

- **`version` is the configured API version, not a product version.** The handler
overwrites the protocol's value with `api.version` — the same string that forms the
path segment (`"v1"`). It is never a semantic version like `2.1.0`.
- **There is no `name`, `environment` or `locale` here.** Those are dispatcher fields
(see below). A client that initialises i18n from `locale` must read
`/.well-known/objectstack`, not this response.
- **`name` is canonical; `apiName` is a deprecated alias with the same value.** Both are
emitted today so clients pinned to the old spelling keep working; `apiName` is removed
in **protocol 18** (#4828). Read `name`.
- **`scoping` is added by the REST server**, so clients can detect dual-mode routing;
`environmentId` is present only on the environment-scoped mount
(`/api/v1/environments/:environmentId/...`).

<Callout type="info">
**Both discovery documents now satisfy one schema (#4828).** They used to diverge on the
required identity fields — this response omitted `name` / `environment` / `locale`
entirely, and the dispatcher document below spelled the capability map `features` while
this one spelled it `capabilities`. Both producers are now checked against
`DiscoverySchema` in CI, so a client can read the same keys from either. `environment` is
always one of `production` / `sandbox` / `development` — never a raw `NODE_ENV`.
</Callout>

Disabled/uninstalled route keys are omitted from `routes` entirely rather than set to
`null`; check `services` to tell "not installed" apart from "installed but not yet mounted
here." `capabilities` is a flat map of platform feature flags (`comments`, `automation`,
`cron`, `search`, `export`, `chunkedUpload`, `transactionalBatch`), each derived from what
is actually registered — never hardcoded. See
here." `capabilities` maps each platform capability (`comments`, `automation`, `cron`,
`search`, `export`, `chunkedUpload`, `transactionalBatch`) to a `{ "enabled": … }`
descriptor, each derived from what is actually registered — never hardcoded. See
[API → Discovery](/docs/api#discovery) for the field-by-field reference.

### `GET /.well-known/objectstack`
Expand Down Expand Up @@ -119,14 +135,14 @@ Host: api.acme.com
"ui": "/api/v1/ui",
"i18n": "/api/v1/i18n"
},
"features": {
"search": false,
"websockets": false,
"files": false,
"analytics": false,
"ai": false,
"notifications": false,
"i18n": true
"capabilities": {
"search": { "enabled": false },
"websockets": { "enabled": false },
"files": { "enabled": false },
"analytics": { "enabled": false },
"ai": { "enabled": false },
"notifications": { "enabled": false },
"i18n": { "enabled": true }
},
"services": {
"metadata": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/meta", "provider": "kernel" },
Expand All @@ -144,21 +160,41 @@ Host: api.acme.com
```

`name` and `version` are the dispatcher's own build identity, not your app's name — they
are fixed strings, so do not display them as the deployment's title. `environment` is the
process `NODE_ENV`. `locale` is derived from the registered i18n service (`getDefaultLocale()`
/ `getLocales()`); with no i18n service it degrades to `{ "default": "en", "supported":
["en"], "timezone": "UTC" }`. The body also repeats `routes` under an `endpoints` key as a
backward-compatibility alias, and carries **no** `capabilities` map — that one exists only
on the REST-served response above.
are fixed strings, so do not display them as the deployment's title. `locale` is derived
from the registered i18n service (`getDefaultLocale()` / `getLocales()`); with no i18n
service it degrades to `{ "default": "en", "supported": ["en"], "timezone": "UTC" }`.

`environment` is **derived from** `NODE_ENV`, not the raw value — the field is an enum
(`production` / `sandbox` / `development`), so out-of-enum spellings are mapped rather
than advertised verbatim (#4828):

| `NODE_ENV` | advertised `environment` |
|:---|:---|
| `production`, `prod` | `production` |
| `sandbox` | `sandbox` |
| `staging` | `sandbox` — pre-production and production-like |
| `development`, `dev` | `development` |
| `test` | `development` — an ephemeral developer-class run |
| unset / anything else | `development` — never claims production on a guess |

<Callout type="warn">
**Retired in protocol 17 (#4828):** this document used to carry a top-level `features`
map and an `endpoints` key that duplicated `routes` verbatim. Neither was ever declared
in `DiscoverySchema`. `features` is now the canonical `capabilities` (same flags, in the
declared `{ "enabled": … }` shape, so it matches the REST-served response); `endpoints`
was removed outright after a consumer census across `objectstack`, `objectui` and `cloud`
found no reader — use `routes`.
</Callout>

<Callout type="warn">
**"Both paths return the same document" holds only in a REST-less composition.** There, the
dispatcher owns `/api/v1/discovery` as the fallback registrant, so that path and
`/.well-known/objectstack` both answer with the dispatcher payload above (the bare
`/api/v1` is registered by `@objectstack/rest` alone and is not served at all). As soon as
`@objectstack/rest` is mounted it takes `/api/v1/discovery` under the single-owner rule
(ADR-0076 D11) and the two paths answer different shapes. Never write a client that reads
`locale` or `environment` off `/api/v1/discovery`.
(ADR-0076 D11) and the two paths answer different *documents* — same schema, different
producers, so the envelope (`{ "data": … }` here, bare there) and the values differ even
though the key set no longer does.
</Callout>

**Why discovery matters:**
Expand Down
20 changes: 17 additions & 3 deletions content/docs/references/api/discovery.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ not been verified (may 501 at runtime).
## TypeScript Usage

```typescript
import { ApiRoutesSchema, DiscoverySchema, RouteHealthEntrySchema, RouteHealthReportSchema, ServiceInfoSchema, ServiceSelfInfoSchema, WellKnownCapabilitiesSchema } from '@objectstack/spec/api';
import type { ApiRoutes, RouteHealthEntry, RouteHealthReport, ServiceInfo, ServiceSelfInfo, WellKnownCapabilities } from '@objectstack/spec/api';
import { ApiRoutesSchema, DiscoverySchema, DiscoveryEnvironmentSchema, RouteHealthEntrySchema, RouteHealthReportSchema, ServiceInfoSchema, ServiceSelfInfoSchema, WellKnownCapabilitiesSchema } from '@objectstack/spec/api';
import type { ApiRoutes, DiscoveryEnvironment, RouteHealthEntry, RouteHealthReport, ServiceInfo, ServiceSelfInfo, WellKnownCapabilities } from '@objectstack/spec/api';

// Validate data
const result = ApiRoutesSchema.parse(data);
Expand Down Expand Up @@ -69,15 +69,29 @@ const result = ApiRoutesSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | |
| **version** | `string` | ✅ | |
| **environment** | `Enum<'production' \| 'sandbox' \| 'development'>` | ✅ | |
| **environment** | `Enum<'production' \| 'sandbox' \| 'development'>` | ✅ | Deployment posture a discovery response advertises. Deliberately three coarse buckets — a client reads this to answer "am I talking to production?", not to identify a specific environment (that is `sys_environment` / EnvironmentTypeSchema, a richer 7-member taxonomy). |
| **routes** | `{ data: string; metadata: string; discovery?: string; ui?: string; … }` | ✅ | |
| **locale** | `{ default: string; supported: string[]; timezone: string }` | ✅ | |
| **services** | `Record<string, { enabled: boolean; status: Enum<'available' \| 'registered' \| 'unavailable' \| 'degraded' \| 'stub'>; handlerReady?: boolean; route?: string; … }>` | ✅ | Per-service availability map keyed by CoreServiceName |
| **capabilities** | `Record<string, { enabled: boolean; features?: Record<string, boolean>; description?: string }>` | optional | Hierarchical capability descriptors for frontend intelligent adaptation |
| **schemaDiscovery** | `{ openapi?: string; jsonSchema?: string }` | optional | Schema discovery endpoints for API toolchain integration |
| **scoping** | `{ enabled: boolean; resolution: Enum<'required' \| 'optional' \| 'auto'>; scoped: boolean; environmentId?: string }` | optional | Environment-scoping posture, added by the REST discovery endpoint |
| **metadata** | `Record<string, any>` | optional | Custom metadata key-value pairs for extensibility |


---

## DiscoveryEnvironment

Deployment posture a discovery response advertises. Deliberately three coarse buckets — a client reads this to answer "am I talking to production?", not to identify a specific environment (that is `sys_environment` / EnvironmentTypeSchema, a richer 7-member taxonomy).

### Allowed Values

* `production`
* `sandbox`
* `development`


---

## RouteHealthEntry
Expand Down
5 changes: 3 additions & 2 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -594,14 +594,15 @@ const result = AiAgentCapabilitiesSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **name** | `string` | optional | |
| **version** | `string` | ✅ | |
| **environment** | `Enum<'production' \| 'sandbox' \| 'development'>` | optional | |
| **environment** | `Enum<'production' \| 'sandbox' \| 'development'>` | optional | Deployment posture a discovery response advertises. Deliberately three coarse buckets — a client reads this to answer "am I talking to production?", not to identify a specific environment (that is `sys_environment` / EnvironmentTypeSchema, a richer 7-member taxonomy). |
| **routes** | `{ data: string; metadata: string; discovery?: string; ui?: string; … }` | optional | |
| **locale** | `{ default: string; supported: string[]; timezone: string }` | optional | |
| **services** | `Record<string, { enabled: boolean; status: Enum<'available' \| 'registered' \| 'unavailable' \| 'degraded' \| 'stub'>; handlerReady?: boolean; route?: string; … }>` | optional | Per-service availability map keyed by CoreServiceName |
| **capabilities** | `Record<string, { enabled: boolean; features?: Record<string, boolean>; description?: string }>` | optional | Hierarchical capability descriptors for frontend intelligent adaptation |
| **schemaDiscovery** | `{ openapi?: string; jsonSchema?: string }` | optional | Schema discovery endpoints for API toolchain integration |
| **scoping** | `{ enabled: boolean; resolution: Enum<'required' \| 'optional' \| 'auto'>; scoped: boolean; environmentId?: string }` | optional | Environment-scoping posture, added by the REST discovery endpoint |
| **metadata** | `Record<string, any>` | optional | Custom metadata key-value pairs for extensibility |
| **apiName** | `string` | optional | API name (deprecated — use name) |
| **apiName** | `string` | optional | API name (deprecated — use `name`; removed in protocol 18) |


---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ directory rather than per file.
| Dir | Sites |
|---|---|
| `ai/` | 77 |
| `api/` | 393 |
| `api/` | 394 |
| `cloud/` | 82 |
| `identity/` | 33 |
| `integration/` | 10 |
Expand Down
2 changes: 1 addition & 1 deletion packages/adapters/hono/src/__mocks__/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { vi } from 'vitest';

export class HttpDispatcher {
getDiscoveryInfo = vi.fn().mockReturnValue({ version: '1.0', endpoints: [] });
getDiscoveryInfo = vi.fn().mockReturnValue({ version: '1.0', routes: {} });
handleGraphQL = vi.fn().mockResolvedValue({ data: {} });
handleAuth = vi.fn().mockResolvedValue({ handled: true, response: { status: 200, body: { ok: true } } });
handleMetadata = vi.fn().mockResolvedValue({ handled: true, response: { status: 200, body: { objects: [] } } });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Hono } from 'hono';

const mockDispatcher = {
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', endpoints: [] }),
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }),
handleAuth: vi.fn(),
handleGraphQL: vi.fn(),
dispatch: vi.fn(),
Expand Down
2 changes: 1 addition & 1 deletion packages/adapters/hono/src/hono.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Hono } from 'hono';

// Mock dispatcher instance accessible across tests
const mockDispatcher = {
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', endpoints: [] }),
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }),
handleAuth: vi.fn().mockResolvedValue({ handled: true, response: { body: { ok: true }, status: 200 } }),
handleGraphQL: vi.fn().mockResolvedValue({ data: {} }),
dispatch: vi.fn().mockResolvedValue({ handled: true, response: { body: { success: true }, status: 200 } }),
Expand Down
Loading
Loading