diff --git a/.changeset/discovery-surface-schema-authoritative.md b/.changeset/discovery-surface-schema-authoritative.md
new file mode 100644
index 0000000000..2083595e02
--- /dev/null
+++ b/.changeset/discovery-surface-schema-authoritative.md
@@ -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`.
diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx
index 6f970d4f83..165d15a09c 100644
--- a/content/docs/protocol/kernel/http-protocol.mdx
+++ b/content/docs/protocol/kernel/http-protocol.mdx
@@ -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`)
@@ -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" },
@@ -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/...`).
+
+**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`.
+
+
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`
@@ -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" },
@@ -144,12 +160,31 @@ 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 |
+
+
+**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`.
+
**"Both paths return the same document" holds only in a REST-less composition.** There, the
@@ -157,8 +192,9 @@ 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.
**Why discovery matters:**
diff --git a/content/docs/references/api/discovery.mdx b/content/docs/references/api/discovery.mdx
index f6d2635440..dfd6078da1 100644
--- a/content/docs/references/api/discovery.mdx
+++ b/content/docs/references/api/discovery.mdx
@@ -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);
@@ -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; handlerReady?: boolean; route?: string; … }>` | ✅ | Per-service availability map keyed by CoreServiceName |
| **capabilities** | `Record; 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` | 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
diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx
index 438c77631d..cab71694fc 100644
--- a/content/docs/references/api/protocol.mdx
+++ b/content/docs/references/api/protocol.mdx
@@ -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; handlerReady?: boolean; route?: string; … }>` | optional | Per-service availability map keyed by CoreServiceName |
| **capabilities** | `Record; 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` | 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) |
---
diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
index 39c1f21bc7..f77d44a7aa 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
@@ -260,7 +260,7 @@ directory rather than per file.
| Dir | Sites |
|---|---|
| `ai/` | 77 |
-| `api/` | 393 |
+| `api/` | 394 |
| `cloud/` | 82 |
| `identity/` | 33 |
| `integration/` | 10 |
diff --git a/packages/adapters/hono/src/__mocks__/runtime.ts b/packages/adapters/hono/src/__mocks__/runtime.ts
index 5306d26684..b7d188ef1a 100644
--- a/packages/adapters/hono/src/__mocks__/runtime.ts
+++ b/packages/adapters/hono/src/__mocks__/runtime.ts
@@ -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: [] } } });
diff --git a/packages/adapters/hono/src/hono-wildcard-fallthrough.test.ts b/packages/adapters/hono/src/hono-wildcard-fallthrough.test.ts
index 43364309cc..fbeecab0c2 100644
--- a/packages/adapters/hono/src/hono-wildcard-fallthrough.test.ts
+++ b/packages/adapters/hono/src/hono-wildcard-fallthrough.test.ts
@@ -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(),
diff --git a/packages/adapters/hono/src/hono.test.ts b/packages/adapters/hono/src/hono.test.ts
index 1aa9b8bb05..2ff6fb3c74 100644
--- a/packages/adapters/hono/src/hono.test.ts
+++ b/packages/adapters/hono/src/hono.test.ts
@@ -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 } }),
diff --git a/packages/metadata-protocol/src/discovery-schema-conformance.test.ts b/packages/metadata-protocol/src/discovery-schema-conformance.test.ts
new file mode 100644
index 0000000000..d00d5b7394
--- /dev/null
+++ b/packages/metadata-protocol/src/discovery-schema-conformance.test.ts
@@ -0,0 +1,104 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// [#4828] The discovery surface is machine-readable (AGENTS.md "Route & surface
+// ownership" #4: machine-readable surfaces must not lie), and until this issue
+// NOTHING compared what a producer actually emits against what the spec
+// declares. `GetDiscoveryResponseSchema` — the only schema the protocol layer
+// referenced — is `DiscoverySchema.partial().required({version}).extend({apiName})`,
+// and a zod object strips unknown keys by default. So BOTH failure directions
+// were swallowed at once:
+//
+// 1. missing REQUIRED keys — `.partial()` made `name`/`environment`/`locale`
+// optional, so this producer never emitting them parsed clean;
+// 2. UNDECLARED keys — the default strip made `features` / `endpoints`
+// (emitted by the runtime dispatcher) parse clean too.
+//
+// The maintainer's 2026-08-05 ruling makes `DiscoverySchema` authoritative:
+// every producer fills the required keys. These tests are that gate for the
+// `getDiscovery()` producer; `packages/runtime` and `packages/rest` carry the
+// sibling gates for the other two shapes.
+//
+// The two assertions are deliberately different questions (key vs value):
+//
+// * `DiscoverySchema.parse()` judges VALUES — required keys present,
+// `environment` inside its declared enum, `scoping` well-formed.
+// * the key-set subset check judges KEYS — nothing is emitted that the
+// protocol never declared. Its allowed set is exactly
+// `GetDiscoveryResponseSchema`'s shape, i.e. `DiscoverySchema`'s keys plus
+// the one declared deprecated alias (`apiName`). Deriving the allowance
+// from the schema instead of a hand-listed array is what stops this gate
+// from becoming a third dialect of the contract.
+
+import { describe, it, expect } from 'vitest';
+import { DiscoverySchema, GetDiscoveryResponseSchema } from '@objectstack/spec/api';
+import { ObjectStackProtocolImplementation } from './index.js';
+
+/** The keys the protocol declares for a discovery response (canonical + declared alias). */
+function declaredResponseKeys(): Set {
+ return new Set(Object.keys((GetDiscoveryResponseSchema as any).shape));
+}
+
+/**
+ * A protocol impl over a minimal engine. `getDiscovery()` reads
+ * `engine.registry` (for `sys_comment`), `engine.transaction` (for
+ * `transactionalBatch`) and the services registry — nothing else.
+ */
+function makeImpl(services: Map = new Map()) {
+ const engine = {
+ registry: {
+ getObject: (_name: string) => undefined,
+ getRegisteredTypes: () => [],
+ },
+ };
+ return new ObjectStackProtocolImplementation(engine as any, () => services);
+}
+
+describe('[#4828] getDiscovery() conforms to DiscoverySchema', () => {
+ it('emits every REQUIRED key the spec declares (name / environment / locale)', async () => {
+ const discovery = await makeImpl().getDiscovery();
+
+ const result = DiscoverySchema.safeParse(discovery);
+ expect(
+ result.success ? [] : result.error.issues.map(i => `${i.path.join('.')}: ${i.code}`),
+ 'getDiscovery() must satisfy the canonical DiscoverySchema',
+ ).toEqual([]);
+ });
+
+ it('emits NO key the protocol does not declare', async () => {
+ const discovery = await makeImpl().getDiscovery();
+
+ const declared = declaredResponseKeys();
+ const undeclared = Object.keys(discovery).filter(k => !declared.has(k));
+ expect(undeclared, 'undeclared top-level keys on the getDiscovery() shape').toEqual([]);
+ });
+
+ it('carries the canonical `name`, and keeps `apiName` for its deprecation window', async () => {
+ const discovery: any = await makeImpl().getDiscovery();
+
+ // Canonical (required by DiscoverySchema).
+ expect(discovery.name).toBe('ObjectStack API');
+ // Deprecated alias — still emitted so a client pinned to it (the
+ // `01-discovery.test.ts` integration assertion) keeps working until the
+ // scheduled removal. Both spellings must name the SAME thing.
+ expect(discovery.apiName).toBe(discovery.name);
+ });
+
+ it('reports an `environment` inside the declared enum', async () => {
+ const discovery: any = await makeImpl().getDiscovery();
+
+ expect(['production', 'sandbox', 'development']).toContain(discovery.environment);
+ });
+
+ it('reports a `locale` block derived from the i18n service when one is registered', async () => {
+ const services = new Map([
+ ['i18n', {
+ getDefaultLocale: () => 'zh-CN',
+ getLocales: () => ['zh-CN', 'en'],
+ }],
+ ]);
+ const discovery: any = await makeImpl(services).getDiscovery();
+
+ expect(discovery.locale.default).toBe('zh-CN');
+ expect(discovery.locale.supported).toEqual(['zh-CN', 'en']);
+ });
+});
diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts
index 8961c23074..80ebe63105 100644
--- a/packages/metadata-protocol/src/protocol.ts
+++ b/packages/metadata-protocol/src/protocol.ts
@@ -19,7 +19,7 @@ import type {
} from '@objectstack/spec/api';
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api';
import type { ApiError, BatchOperationResult } from '@objectstack/spec/api';
-import { readServiceSelfInfo, ErrorCode, standardErrorCodeForHttpStatus } from '@objectstack/spec/api';
+import { readServiceSelfInfo, ErrorCode, standardErrorCodeForHttpStatus, resolveDiscoveryEnvironment } from '@objectstack/spec/api';
import {
parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf,
AggregationFunction, DateGranularity, resolveSearchFieldResolution,
@@ -2734,10 +2734,47 @@ export class ObjectStackProtocolImplementation implements
capabilities[key] = { enabled };
}
+ // [#4828] Locale, derived from the registered i18n service exactly the
+ // way the runtime dispatcher's `getDiscoveryInfo()` derives it — same
+ // accessors, same fallback. `DiscoverySchema` declares `locale`
+ // REQUIRED and this producer never emitted it, so the REST `/discovery`
+ // shape could not satisfy the schema at all; deriving it (rather than
+ // hardcoding `en`) keeps the answer honest on a stack that actually
+ // ships translations.
+ const i18nSvc = registeredServices.get('i18n');
+ let locale = { default: 'en', supported: ['en'], timezone: 'UTC' };
+ if (i18nSvc) {
+ const defaultLocale = typeof i18nSvc.getDefaultLocale === 'function'
+ ? i18nSvc.getDefaultLocale() : 'en';
+ const locales = typeof i18nSvc.getLocales === 'function'
+ ? i18nSvc.getLocales() : [];
+ locale = {
+ default: defaultLocale,
+ supported: locales.length > 0 ? locales : [defaultLocale],
+ timezone: 'UTC',
+ };
+ }
+
+ // [#4828] `name` is the canonical identity key (`DiscoverySchema`
+ // requires it); `apiName` is the deprecated alias, emitted with the
+ // IDENTICAL value until its scheduled removal in protocol 18 (schedule
+ // in `GetDiscoveryResponseSchema`). Before this, the two discovery
+ // producers spelled the same concept differently and disjointly — this
+ // one emitted only `apiName`, the dispatcher only `name` — so no
+ // consumer had a key that worked against both.
+ const name = 'ObjectStack API';
+
return {
version: '1.0',
- apiName: 'ObjectStack API',
+ name,
+ /** @deprecated Use `name`. Removed in protocol 18 (#4828). */
+ apiName: name,
+ environment: resolveDiscoveryEnvironment(
+ (globalThis as { process?: { env?: Record } })
+ .process?.env?.NODE_ENV,
+ ),
routes,
+ locale,
services,
capabilities,
};
diff --git a/packages/rest/src/discovery-schema-conformance.test.ts b/packages/rest/src/discovery-schema-conformance.test.ts
new file mode 100644
index 0000000000..d7b562e31e
--- /dev/null
+++ b/packages/rest/src/discovery-schema-conformance.test.ts
@@ -0,0 +1,143 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// [#4828] The REST `/discovery` half of the conformance gate — and the one that
+// matters most, because this is the shape a browser client actually receives.
+//
+// It is a COMPOSED shape: `getDiscovery()` (metadata-protocol) builds the base,
+// then `registerDiscoveryEndpoints` overrides `version` and `routes`, ANDs
+// `capabilities.transactionalBatch` with its own `api.enableBatch`, and attaches
+// `scoping`. Neither producer alone could be checked against the schema and be
+// meaningful here, so this test drives the REAL protocol implementation through
+// the REAL handler rather than the `createMockProtocol()` double the other
+// rest tests use — a mock would only prove the mock's shape conforms.
+//
+// Before this issue the composed shape could not satisfy `DiscoverySchema` at
+// all: `name`/`environment`/`locale` were absent (required), and `scoping` was
+// undeclared.
+
+import { describe, it, expect, vi } from 'vitest';
+import { DiscoverySchema, GetDiscoveryResponseSchema } from '@objectstack/spec/api';
+import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
+import { RestServer } from './rest-server.js';
+
+/** The keys the protocol declares for a discovery response (canonical + declared alias). */
+function declaredResponseKeys(): Set {
+ return new Set(Object.keys((GetDiscoveryResponseSchema as any).shape));
+}
+
+function createMockServer() {
+ return {
+ get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
+ use: vi.fn(),
+ listen: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ };
+}
+
+/**
+ * A REST server over the REAL `getDiscovery()` producer.
+ *
+ * `scoping` selects the mount under test: unscoped `/api/v1` (the default
+ * deployment) or the environment-scoped `/api/v1/environments/:environmentId`
+ * one, which is the only mount that can report `scoped: true`.
+ */
+function discoveryHandler(opts: { scoped?: boolean } = {}) {
+ const engine = {
+ registry: {
+ getObject: (_n: string) => undefined,
+ getRegisteredTypes: () => [],
+ },
+ };
+ const protocol = new ObjectStackProtocolImplementation(engine as any, () => new Map());
+ const config: any = {
+ api: {
+ requireAuth: false,
+ ...(opts.scoped ? { enableProjectScoping: true, projectResolution: 'auto' } : {}),
+ },
+ };
+ const rest = new RestServer(createMockServer() as any, protocol as any, config);
+ rest.registerRoutes();
+
+ const path = opts.scoped
+ ? '/api/v1/environments/:environmentId/discovery'
+ : '/api/v1/discovery';
+ const entry = rest.getRouteManager().get('GET', path);
+ if (!entry) throw new Error(`discovery route not registered at ${path}`);
+ return entry.handler as (req: any, res: any) => Promise;
+}
+
+async function invoke(
+ handler: (req: any, res: any) => Promise,
+ params: Record = {},
+) {
+ let body: any;
+ const res: any = { json: (b: any) => { body = b; }, status: () => res };
+ await handler({ params }, res);
+ return body;
+}
+
+describe('[#4828] the REST /discovery live shape conforms to DiscoverySchema', () => {
+ it('satisfies the canonical DiscoverySchema', async () => {
+ const body = await invoke(discoveryHandler());
+
+ const result = DiscoverySchema.safeParse(body);
+ expect(
+ result.success ? [] : result.error.issues.map(i => `${i.path.join('.')}: ${i.code}`),
+ 'the REST /discovery body must satisfy the canonical DiscoverySchema',
+ ).toEqual([]);
+ });
+
+ it('emits NO key the protocol does not declare', async () => {
+ const body = await invoke(discoveryHandler());
+
+ const declared = declaredResponseKeys();
+ const undeclared = Object.keys(body).filter(k => !declared.has(k));
+ expect(undeclared, 'undeclared top-level keys on the REST /discovery body').toEqual([]);
+ });
+
+ it('fills the three required identity keys the schema declares', async () => {
+ const body = await invoke(discoveryHandler());
+
+ expect(body.name).toBe('ObjectStack API');
+ expect(['production', 'sandbox', 'development']).toContain(body.environment);
+ expect(body.locale).toEqual({ default: 'en', supported: ['en'], timezone: 'UTC' });
+ });
+
+ it('declares `scoping` — reported, and now schema-checked, on the unscoped mount', async () => {
+ const body = await invoke(discoveryHandler());
+
+ expect(body.scoping).toEqual({
+ enabled: false,
+ resolution: 'auto',
+ scoped: false,
+ environmentId: undefined,
+ });
+ });
+
+ it('reports the resolved environmentId on the scoped mount', async () => {
+ const body = await invoke(
+ discoveryHandler({ scoped: true }),
+ { environmentId: 'env_alpha' },
+ );
+
+ expect(body.scoping).toEqual({
+ enabled: true,
+ resolution: 'auto',
+ scoped: true,
+ environmentId: 'env_alpha',
+ });
+ // Still schema-clean with every `scoping` sub-key populated.
+ expect(DiscoverySchema.safeParse(body).success).toBe(true);
+ });
+
+ it('keeps `capabilities` as the one capability key — no `features`, no `endpoints`', async () => {
+ const body = await invoke(discoveryHandler());
+
+ expect(body).not.toHaveProperty('features');
+ expect(body).not.toHaveProperty('endpoints');
+ // The REST layer's own AND of the runtime verdict with `api.enableBatch`
+ // still lands inside `capabilities` (#3298) — pinned so the retirement
+ // cannot take the composed capability with it.
+ expect(body.capabilities.transactionalBatch).toBeDefined();
+ });
+});
diff --git a/packages/runtime/src/discovery-schema-conformance.test.ts b/packages/runtime/src/discovery-schema-conformance.test.ts
new file mode 100644
index 0000000000..6c306c3952
--- /dev/null
+++ b/packages/runtime/src/discovery-schema-conformance.test.ts
@@ -0,0 +1,114 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// [#4828] The dispatcher's half of the discovery conformance gate. See the
+// sibling file in `packages/metadata-protocol` for the full reasoning; in short,
+// `GetDiscoveryResponseSchema` is `DiscoverySchema.partial()` and zod strips
+// unknown keys, so the ONLY schema the protocol layer referenced could see
+// neither a missing required key nor an undeclared emitted one. This dispatcher
+// shape was the worst case of both: it emitted `features` and `endpoints`
+// (declared nowhere) and passed `NODE_ENV` through raw into a field the spec
+// declares as an ENUM.
+//
+// The `NODE_ENV` half is self-demonstrating here: vitest sets `NODE_ENV=test`,
+// which is not a member of `production|sandbox|development`. Before the fix the
+// `environment` assertion below fails **in the very run that proves it** — no
+// contrived fixture needed.
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { DiscoverySchema, GetDiscoveryResponseSchema } from '@objectstack/spec/api';
+import { HttpDispatcher } from './http-dispatcher.js';
+
+/** The keys the protocol declares for a discovery response (canonical + declared alias). */
+function declaredResponseKeys(): Set {
+ return new Set(Object.keys((GetDiscoveryResponseSchema as any).shape));
+}
+
+describe('[#4828] getDiscoveryInfo() conforms to DiscoverySchema', () => {
+ let dispatcher: HttpDispatcher;
+
+ beforeEach(() => {
+ const kernel = {
+ context: {
+ getService: (name: string) => {
+ if (name === 'objectql') {
+ return {
+ registry: {
+ getObject: vi.fn().mockReturnValue({ name: 'test_obj' }),
+ getRegisteredTypes: vi.fn().mockReturnValue([]),
+ getAllPackages: vi.fn().mockReturnValue([]),
+ },
+ };
+ }
+ return null;
+ },
+ },
+ } as any;
+ dispatcher = new HttpDispatcher(kernel);
+ });
+
+ it('satisfies the canonical DiscoverySchema', async () => {
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
+
+ const result = DiscoverySchema.safeParse(info);
+ expect(
+ result.success ? [] : result.error.issues.map(i => `${i.path.join('.')}: ${i.code}`),
+ 'getDiscoveryInfo() must satisfy the canonical DiscoverySchema',
+ ).toEqual([]);
+ });
+
+ it('emits NO key the protocol does not declare', async () => {
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
+
+ const declared = declaredResponseKeys();
+ const undeclared = Object.keys(info).filter(k => !declared.has(k));
+ expect(undeclared, 'undeclared top-level keys on the getDiscoveryInfo() shape').toEqual([]);
+ });
+
+ it('has retired `features` and `endpoints` (ADR-0049 enforce-or-remove)', async () => {
+ const info: any = await dispatcher.getDiscoveryInfo('/api/v1');
+
+ // `features` → the canonical `capabilities`; the flags themselves survive.
+ expect(info).not.toHaveProperty('features');
+ expect(info.capabilities.search.enabled).toBe(false);
+ expect(info.capabilities.websockets.enabled).toBe(false);
+
+ // `endpoints` was a verbatim duplicate of `routes` with no measured reader.
+ expect(info).not.toHaveProperty('endpoints');
+ expect(info.routes).toBeDefined();
+ });
+
+ describe('maps NODE_ENV into the declared enum instead of passing it through', () => {
+ const OLD_NODE_ENV = process.env.NODE_ENV;
+ afterEach(() => {
+ if (OLD_NODE_ENV === undefined) delete process.env.NODE_ENV;
+ else process.env.NODE_ENV = OLD_NODE_ENV;
+ });
+
+ // The two out-of-enum spellings named in the issue, driven through the REAL
+ // producer — `test` is what vitest itself sets, so the raw passthrough was
+ // advertising an undeclared value in every test run of this repo.
+ it.each([
+ ['test', 'development'],
+ ['staging', 'sandbox'],
+ ['production', 'production'],
+ ['qa', 'development'],
+ ])('NODE_ENV=%s advertises %s', async (nodeEnv, expected) => {
+ process.env.NODE_ENV = nodeEnv;
+
+ const info: any = await dispatcher.getDiscoveryInfo('/api/v1');
+
+ expect(info.environment).toBe(expected);
+ // …and the whole body still satisfies the schema with that value in place.
+ expect(DiscoverySchema.safeParse(info).success).toBe(true);
+ });
+ });
+
+ it('emits the canonical `name`, never the deprecated `apiName` alias', async () => {
+ const info: any = await dispatcher.getDiscoveryInfo('/api/v1');
+
+ expect(info.name).toBe('ObjectOS');
+ // This producer was already canonical-only; pin it so the alias cannot be
+ // reintroduced here while it is being retired on the other producer.
+ expect(info).not.toHaveProperty('apiName');
+ });
+});
diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts
index e00afd65b2..38d4679d4e 100644
--- a/packages/runtime/src/http-dispatcher.test.ts
+++ b/packages/runtime/src/http-dispatcher.test.ts
@@ -2420,7 +2420,7 @@ describe('HttpDispatcher', () => {
expect(info.services.i18n.enabled).toBe(true);
expect(info.services.i18n.status).toBe('available');
expect(info.routes.i18n).toBe('/api/v1/i18n');
- expect(info.features.i18n).toBe(true);
+ expect(info.capabilities.i18n.enabled).toBe(true);
});
it('should report i18n as unavailable in discovery when service is not registered', async () => {
@@ -2431,7 +2431,7 @@ describe('HttpDispatcher', () => {
expect(info.services.i18n.enabled).toBe(false);
expect(info.services.i18n.status).toBe('unavailable');
expect(info.routes.i18n).toBeUndefined();
- expect(info.features.i18n).toBe(false);
+ expect(info.capabilities.i18n.enabled).toBe(false);
});
// [#4093 follow-up] Discovery's remedy line must name a package that can
@@ -2467,7 +2467,7 @@ describe('HttpDispatcher', () => {
const info = await dispatcher.getDiscoveryInfo('/api/v1');
expect(info.services).not.toHaveProperty('workflow');
expect(info.routes).not.toHaveProperty('workflow');
- expect(info.features).not.toHaveProperty('workflow');
+ expect(info.capabilities).not.toHaveProperty('workflow');
});
it('never emits the old slot-name-derived template', async () => {
@@ -2585,7 +2585,7 @@ describe('HttpDispatcher', () => {
// No HTTP/WS surface exists — a discovery-advertised route would 404.
expect(info.routes.realtime).toBeUndefined();
- expect(info.features.websockets).toBe(false);
+ expect(info.capabilities.websockets.enabled).toBe(false);
expect(info.services.realtime.enabled).toBe(true);
expect(info.services.realtime.status).toBe('degraded');
expect(info.services.realtime.handlerReady).toBe(false);
@@ -2647,7 +2647,7 @@ describe('HttpDispatcher', () => {
const info = await dispatcher.getDiscoveryInfo('/api/v1');
expect(info.routes.analytics).toBeUndefined();
- expect(info.features.analytics).toBe(false);
+ expect(info.capabilities.analytics.enabled).toBe(false);
expect(info.services.analytics.enabled).toBe(true);
expect(info.services.analytics.status).toBe('stub');
expect(info.services.analytics.handlerReady).toBe(false);
@@ -3037,7 +3037,7 @@ describe('HttpDispatcher', () => {
expect(info.routes[key], `routes.${key}`).toBeUndefined();
}
for (const key of ['files', 'ai', 'notifications', 'i18n'] as const) {
- expect(info.features[key], `features.${key}`).toBe(false);
+ expect(info.capabilities[key].enabled, `capabilities.${key}.enabled`).toBe(false);
}
for (const key of ['file-storage', 'automation', 'notification', 'ai', 'i18n'] as const) {
expect(info.services[key].enabled, `services.${key}.enabled`).toBe(true);
@@ -3064,8 +3064,8 @@ describe('HttpDispatcher', () => {
expect(info.routes.notifications).toBe('/api/v1/notifications');
expect(info.routes.ai).toBe('/api/v1/ai');
expect(info.routes.i18n).toBe('/api/v1/i18n');
- expect(info.features.files).toBe(true);
- expect(info.features.i18n).toBe(true);
+ expect(info.capabilities.files.enabled).toBe(true);
+ expect(info.capabilities.i18n.enabled).toBe(true);
expect(info.services['file-storage'].status).toBe('degraded');
expect(info.services['file-storage'].handlerReady).toBe(true);
});
diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts
index 7ef81d0758..5b650b7386 100644
--- a/packages/runtime/src/http-dispatcher.ts
+++ b/packages/runtime/src/http-dispatcher.ts
@@ -7,7 +7,7 @@ import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE
import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability';
import { CoreServiceName, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system';
import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts';
-import { readServiceSelfInfo, DispatcherErrorCode } from '@objectstack/spec/api';
+import { readServiceSelfInfo, DispatcherErrorCode, resolveDiscoveryEnvironment } from '@objectstack/spec/api';
import { apiErrorResponse } from './error-envelope.js';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import { DomainHandlerRegistry, type DomainRoute, type DomainHandlerDeps } from './domain-handler-registry.js';
@@ -1274,20 +1274,51 @@ export class HttpDispatcher {
return {
name: 'ObjectOS',
version: '1.0.0',
- environment: getEnv('NODE_ENV', 'development'),
+ // [#4828] Mapped, not passed through. `DiscoverySchema.environment`
+ // is an ENUM (`production|sandbox|development`) and this used to be
+ // `getEnv('NODE_ENV', 'development')` raw — so `NODE_ENV=test` (what
+ // vitest sets) or `staging` advertised a value outside the declared
+ // enum on a machine-readable surface. The mapping table and the
+ // reasoning per row live with the enum, in `@objectstack/spec/api`,
+ // so both discovery producers answer identically.
+ environment: resolveDiscoveryEnvironment(getEnv('NODE_ENV', 'development')),
routes,
- endpoints: routes, // Alias for backward compatibility with some clients
- features: {
- search: hasSearch,
+ // [#4828] `endpoints` (a verbatim duplicate of `routes`, commented
+ // "Alias for backward compatibility with some clients") and the
+ // top-level `features` map are GONE. Neither was ever declared in
+ // `DiscoverySchema`; both survived only because the one schema the
+ // protocol layer referenced (`GetDiscoveryResponseSchema`) strips
+ // unknown keys. Per ADR-0049 enforce-or-remove and the 2026-08-05
+ // ruling on #4828:
+ //
+ // * `endpoints` — REMOVED. A consumer census across `objectstack`,
+ // `objectui` and `cloud` (2026-08-05) found NO reader: this repo's
+ // own SDK resolves routes via `discoveryInfo.routes`
+ // (`packages/client/src/index.ts`), and objectui's only
+ // `.endpoints` reads are its hand-written `SERVICE_ENDPOINT_CATALOG`
+ // (`apps/console/.../useApiDiscovery.ts`), not this payload.
+ // `routes` is the declared, canonical spelling.
+ // * `features` — RENAMED to the canonical `capabilities`, which
+ // `DiscoverySchema` has always declared and which this producer
+ // never emitted. That split is the bug: 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`.
+ //
+ // The hierarchical `{ enabled }` shape is what `capabilities`
+ // declares (and what the `getDiscovery()` producer already emits),
+ // so a client reads ONE shape from either producer.
+ capabilities: {
+ search: { enabled: hasSearch },
// No WS/HTTP realtime surface is mounted anywhere — a mere
// in-process realtime service must not advertise websockets
// (ADR-0076 D12, #2462).
- websockets: false,
- files: hasFiles,
- analytics: hasAnalytics,
- ai: hasAi,
- notifications: hasNotification,
- i18n: hasI18n,
+ websockets: { enabled: false },
+ files: { enabled: hasFiles },
+ analytics: { enabled: hasAnalytics },
+ ai: { enabled: hasAi },
+ notifications: { enabled: hasNotification },
+ i18n: { enabled: hasI18n },
},
services: {
// Kernel-provided (always served by the protocol implementation)
diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json
index 0b801ceadb..d01de75d12 100644
--- a/packages/spec/api-surface.json
+++ b/packages/spec/api-surface.json
@@ -2530,6 +2530,8 @@
"DisablePackageRequestSchema (const)",
"DisablePackageResponse (type)",
"DisablePackageResponseSchema (const)",
+ "DiscoveryEnvironment (type)",
+ "DiscoveryEnvironmentSchema (const)",
"DiscoveryResponse (type)",
"DiscoverySchema (const)",
"DispatcherConfig (type)",
@@ -3145,6 +3147,7 @@
"identityFreeEndpointGateFailure (function)",
"normalizeEndpointPath (function)",
"readServiceSelfInfo (function)",
+ "resolveDiscoveryEnvironment (function)",
"standardErrorCodeForHttpStatus (function)",
"validateApiEndpointDeclarations (function)"
],
diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json
index 27f8a2524f..8aa4d5a947 100644
--- a/packages/spec/authorable-surface.json
+++ b/packages/spec/authorable-surface.json
@@ -874,6 +874,7 @@
"api/Discovery:name",
"api/Discovery:routes",
"api/Discovery:schemaDiscovery",
+ "api/Discovery:scoping",
"api/Discovery:services",
"api/Discovery:version",
"api/DispatcherConfig:fallback",
@@ -1062,6 +1063,7 @@
"api/GetDiscoveryResponse:name",
"api/GetDiscoveryResponse:routes",
"api/GetDiscoveryResponse:schemaDiscovery",
+ "api/GetDiscoveryResponse:scoping",
"api/GetDiscoveryResponse:services",
"api/GetDiscoveryResponse:version",
"api/GetEffectivePermissionsResponse:objects",
diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json
index 1fa6bc8de4..da34730f5f 100644
--- a/packages/spec/json-schema.manifest.json
+++ b/packages/spec/json-schema.manifest.json
@@ -182,6 +182,7 @@
"api/DisablePackageRequest",
"api/DisablePackageResponse",
"api/Discovery",
+ "api/DiscoveryEnvironment",
"api/DispatcherConfig",
"api/DispatcherErrorCode",
"api/DispatcherErrorResponse",
diff --git a/packages/spec/src/api/discovery.test.ts b/packages/spec/src/api/discovery.test.ts
index 4e6165fd28..9c7339ea07 100644
--- a/packages/spec/src/api/discovery.test.ts
+++ b/packages/spec/src/api/discovery.test.ts
@@ -9,6 +9,7 @@ import {
RouteHealthReportSchema,
ServiceSelfInfoSchema,
readServiceSelfInfo,
+ resolveDiscoveryEnvironment,
SERVICE_SELF_INFO_KEY,
type DiscoveryResponse,
type ApiRoutes,
@@ -936,3 +937,129 @@ describe('Service self-description marker (ADR-0076 D12, #2462)', () => {
expect(readServiceSelfInfo({ [SERVICE_SELF_INFO_KEY]: 'stub' })).toBeUndefined();
});
});
+
+// ===========================================================================
+// [#4828] The two-schema contract, and the gate that keeps them in step
+// ===========================================================================
+//
+// The discovery surface drifted for as long as it did because ONE lenient
+// schema stood in for two different jobs. `GetDiscoveryResponseSchema` is
+// `DiscoverySchema.partial().required({version}).extend({apiName})`: `.partial()`
+// hid every missing REQUIRED key, and zod's default unknown-key strip hid every
+// UNDECLARED emitted one. Two producers drifted in opposite directions through
+// the same blind spot — one omitted `name`/`environment`/`locale`, the other
+// invented `features` and `endpoints`.
+//
+// The split is now explicit: `DiscoverySchema` binds PRODUCERS, the protocol
+// schema is the CONSUMER's tolerant parse, and the producer-side conformance
+// tests (one per producer package) check emitted keys against the protocol
+// schema's shape. That last step only stays honest while the two shapes are
+// the same modulo declared aliases — which is what this gate pins.
+
+describe('[#4828] DiscoverySchema ↔ GetDiscoveryResponseSchema', () => {
+ /** The one declared deprecated alias, and its removal is scheduled (protocol 18). */
+ const DECLARED_ALIASES = ['apiName'] as const;
+
+ it('the protocol response schema declares exactly DiscoverySchema keys + declared aliases', async () => {
+ const { GetDiscoveryResponseSchema } = await import('./protocol.zod');
+
+ const canonical = Object.keys((DiscoverySchema as any).shape);
+ const response = Object.keys((GetDiscoveryResponseSchema as any).shape);
+
+ expect(new Set(response)).toEqual(new Set([...canonical, ...DECLARED_ALIASES]));
+ });
+
+ it('anti-vacuity: both shapes are non-trivial and carry the keys this issue is about', () => {
+ const canonical = Object.keys((DiscoverySchema as any).shape);
+
+ expect(canonical.length).toBeGreaterThan(5);
+ // Canonical capability key — the ruling's winner over `features`.
+ expect(canonical).toContain('capabilities');
+ // Newly declared (#4828 decision 3).
+ expect(canonical).toContain('scoping');
+ // The retired spellings must NOT come back as declared keys.
+ expect(canonical).not.toContain('features');
+ expect(canonical).not.toContain('endpoints');
+ });
+});
+
+describe('[#4828] scoping (decision 3 — declare what REST actually emits)', () => {
+ const base = {
+ name: 'ObjectStack',
+ version: '1.0.0',
+ environment: 'development',
+ routes: { data: '/api/v1/data', metadata: '/api/v1/meta' },
+ services: minimalServices,
+ locale: { default: 'en', supported: ['en'], timezone: 'UTC' },
+ };
+
+ it('accepts the shape the REST discovery endpoint emits on a scoped mount', () => {
+ const parsed = DiscoverySchema.parse({
+ ...base,
+ scoping: { enabled: true, resolution: 'auto', scoped: true, environmentId: 'env_alpha' },
+ });
+ expect(parsed.scoping?.environmentId).toBe('env_alpha');
+ });
+
+ it('accepts an unscoped mount, where environmentId is absent', () => {
+ const parsed = DiscoverySchema.parse({
+ ...base,
+ scoping: { enabled: false, resolution: 'auto', scoped: false },
+ });
+ expect(parsed.scoping?.scoped).toBe(false);
+ expect(parsed.scoping?.environmentId).toBeUndefined();
+ });
+
+ it('is optional — the dispatcher producer mounts no scoped variant and emits none', () => {
+ expect(DiscoverySchema.parse(base).scoping).toBeUndefined();
+ });
+
+ it('rejects a resolution outside RestApiConfig.projectResolution', () => {
+ expect(() => DiscoverySchema.parse({
+ ...base,
+ scoping: { enabled: true, resolution: 'whenever', scoped: true },
+ })).toThrow();
+ });
+});
+
+describe('[#4828] resolveDiscoveryEnvironment (decision 4 — enum, not passthrough)', () => {
+ it('maps every documented NODE_ENV spelling into the declared enum', () => {
+ const table: Array<[string, string]> = [
+ ['production', 'production'],
+ ['prod', 'production'],
+ ['sandbox', 'sandbox'],
+ ['staging', 'sandbox'],
+ ['development', 'development'],
+ ['dev', 'development'],
+ ['test', 'development'],
+ ];
+ for (const [raw, expected] of table) {
+ expect(resolveDiscoveryEnvironment(raw), `NODE_ENV=${raw}`).toBe(expected);
+ }
+ });
+
+ it('normalizes case and surrounding whitespace (operator-supplied value)', () => {
+ expect(resolveDiscoveryEnvironment(' Production ')).toBe('production');
+ expect(resolveDiscoveryEnvironment('STAGING')).toBe('sandbox');
+ });
+
+ it('never CLAIMS production for an unset or unrecognized value', () => {
+ for (const raw of [undefined, null, '', 'qa', 'preview', 'nonsense']) {
+ expect(resolveDiscoveryEnvironment(raw as any), String(raw)).toBe('development');
+ }
+ });
+
+ it('every mapped result actually satisfies the declared enum', () => {
+ for (const raw of ['production', 'prod', 'sandbox', 'staging', 'development', 'dev', 'test', 'qa', '']) {
+ const parsed = DiscoverySchema.parse({
+ name: 'ObjectStack',
+ version: '1.0.0',
+ environment: resolveDiscoveryEnvironment(raw),
+ routes: { data: '/api/v1/data', metadata: '/api/v1/meta' },
+ services: minimalServices,
+ locale: { default: 'en', supported: ['en'], timezone: 'UTC' },
+ });
+ expect(parsed.environment).toBeDefined();
+ }
+ });
+});
diff --git a/packages/spec/src/api/discovery.zod.ts b/packages/spec/src/api/discovery.zod.ts
index 70511090a3..a46ca515b4 100644
--- a/packages/spec/src/api/discovery.zod.ts
+++ b/packages/spec/src/api/discovery.zod.ts
@@ -208,14 +208,105 @@ export const ApiRoutesSchema = lazySchema(() => z.object({
* Each service entry includes `enabled`, `status`, `route`, and `provider`.
* - `routes` is a convenience shortcut: a flat map of service-name → route-path
* so that clients can resolve endpoints without iterating the services map.
- * - `capabilities`/`features` was removed because it was fully derivable
- * from `services[x].enabled`. Use `services` to determine feature availability.
+ * - `capabilities` is the ONE canonical name for the hierarchical capability
+ * map (#4828, maintainer ruling 2026-08-05). A top-level `features` key was
+ * emitted by the runtime dispatcher for a while and was never declared here;
+ * it is retired — see {@link DiscoverySchema} `capabilities` below. Note the
+ * surviving `features` is the SUB-key *inside* a capability entry
+ * (`capabilities..features`), which is declared and stays.
+ *
+ * **This schema is authoritative for every producer** (#4828). Both the
+ * `@objectstack/rest` `/discovery` endpoint and the runtime dispatcher's
+ * `getDiscoveryInfo()` must satisfy it — required keys included. It is a
+ * machine-readable surface (AGENTS.md "Route & surface ownership" #4: it must
+ * not lie), so the gate is `DiscoverySchema.parse()` against each producer's
+ * LIVE shape, plus a key-set check that nothing undeclared is emitted. Those
+ * gates live next to each producer:
+ * `packages/metadata-protocol/src/discovery-schema-conformance.test.ts`,
+ * `packages/runtime/src/discovery-schema-conformance.test.ts` and
+ * `packages/rest/src/discovery-schema-conformance.test.ts`.
+ *
+ * Why they were needed at all: the only schema the protocol layer referenced
+ * was `GetDiscoveryResponseSchema` (`./protocol.zod.ts`), which is this schema
+ * `.partial()`-ed — so missing required keys parsed clean — and a zod object
+ * strips unknown keys by default — so undeclared keys parsed clean too. Both
+ * halves of `declared ≠ enforced` were swallowed by one lenient wrapper.
+ */
+export const DiscoveryEnvironmentSchema = lazySchema(() => z
+ .enum(['production', 'sandbox', 'development'])
+ .describe(
+ '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).'
+ ));
+
+export type DiscoveryEnvironment = z.infer;
+
+/**
+ * `NODE_ENV` spellings accepted for each declared discovery environment (#4828).
+ *
+ * `DiscoverySchema.environment` is an enum, and the runtime dispatcher used to
+ * pass `NODE_ENV` through raw — so `NODE_ENV=test` (what vitest sets) or
+ * `staging` advertised a value outside the declared enum on a machine-readable
+ * surface. The maintainer's 2026-08-05 ruling requires every producer's value
+ * to land inside the enum, with the disposition of the out-of-enum spellings
+ * left to this layer and documented.
+ *
+ * Normalizing `NODE_ENV` here is the same move `NODE_ENV_TO_SEED_ENV` already
+ * makes in `packages/metadata-protocol/src/seed-loader.ts`: `NODE_ENV` is an
+ * OPERATOR-supplied variable at a third-party boundary (Prime Directive #9
+ * lists it as exactly that), so normalizing its spellings is not the
+ * consumer-side tolerance PD #12 forbids — that rule governs OUR OWN metadata
+ * contract, and this is the far side of it. The `prod`/`dev` short spellings
+ * are accepted for the same reason they are there: an operator who exported
+ * `NODE_ENV=prod` gets what they meant rather than an indeterminate answer.
+ *
+ * The mapping, and why each row:
+ *
+ * | `NODE_ENV` | advertised | why |
+ * |:------------------------|:---------------|:----|
+ * | `production`, `prod` | `production` | exact / short spelling |
+ * | `sandbox` | `sandbox` | exact |
+ * | `development`, `dev` | `development` | exact / short spelling |
+ * | `test` | `development` | ephemeral developer-class run (vitest/CI), not a provisioned pre-production copy |
+ * | `staging` | `sandbox` | pre-production and production-LIKE; certainly not `production`, and `sandbox` is the enum's pre-production member |
+ * | unset / anything else | `development` | preserves the pre-existing `getEnv('NODE_ENV', 'development')` default, and never CLAIMS production on a guess |
+ *
+ * The last row is the safety-relevant one: an unknown spelling degrades to
+ * `development`, so this function can never advertise `production` for an
+ * environment it failed to recognise.
*/
+const NODE_ENV_TO_DISCOVERY_ENVIRONMENT: Readonly> = {
+ production: 'production',
+ prod: 'production',
+ sandbox: 'sandbox',
+ staging: 'sandbox',
+ development: 'development',
+ dev: 'development',
+ test: 'development',
+};
+
+/**
+ * Map a raw `NODE_ENV` (or any operator-supplied environment string) onto the
+ * `DiscoverySchema.environment` enum.
+ *
+ * Shared by both discovery producers so they cannot drift — the same reason
+ * `serviceUnavailableMessage` / `inProcessServiceMessage` live in
+ * `@objectstack/spec/system` rather than in each builder.
+ *
+ * @param raw the operator-supplied value, typically `process.env.NODE_ENV`
+ * @returns a value guaranteed to satisfy {@link DiscoveryEnvironmentSchema}
+ */
+export function resolveDiscoveryEnvironment(raw?: string | null): DiscoveryEnvironment {
+ if (typeof raw !== 'string') return 'development';
+ return NODE_ENV_TO_DISCOVERY_ENVIRONMENT[raw.trim().toLowerCase()] ?? 'development';
+}
+
export const DiscoverySchema = lazySchema(() => z.object({
/** System Identity */
name: z.string(),
version: z.string(),
- environment: z.enum(['production', 'sandbox', 'development']),
+ environment: DiscoveryEnvironmentSchema,
/** Dynamic Routing — convenience shortcut for client routing */
routes: ApiRoutesSchema,
@@ -259,6 +350,33 @@ export const DiscoverySchema = lazySchema(() => z.object({
jsonSchema: z.string().optional().describe('URL to JSON Schema definitions'),
}).optional().describe('Schema discovery endpoints for API toolchain integration'),
+ /**
+ * Environment-scoping posture of the server that answered (#4828).
+ *
+ * Added by the `@objectstack/rest` discovery endpoint, which is the only
+ * layer that knows it: the REST server can mount the same API twice — once
+ * bare (`/api/v1`) and once environment-scoped
+ * (`/api/v1/environments/:environmentId`) — and a client needs to know which
+ * mode it reached and how the environment id is resolved before it can build
+ * URLs. It was emitted (and consumed — `packages/client`'s
+ * `client.environment-scoping.test.ts` asserts `scoping.enabled` /
+ * `scoping.resolution` off the live response) long before it was declared;
+ * the 2026-08-05 ruling declares it here rather than deleting a real
+ * capability-negotiation fact.
+ *
+ * Optional because only the REST producer can answer it: the runtime
+ * dispatcher serves one kernel and mounts no scoped variant, so it emits
+ * nothing here rather than inventing a value.
+ */
+ scoping: z.object({
+ enabled: z.boolean().describe('Whether environment-scoped routes are mounted at all'),
+ resolution: z.enum(['required', 'optional', 'auto'])
+ .describe('How the environment id is resolved when scoping is enabled (mirrors RestApiConfig.projectResolution)'),
+ scoped: z.boolean().describe('Whether THIS response was served from the environment-scoped mount'),
+ environmentId: z.string().optional()
+ .describe('The resolved environment id — present only on a scoped mount'),
+ }).optional().describe('Environment-scoping posture, added by the REST discovery endpoint'),
+
/**
* Custom metadata key-value pairs for extensibility
*/
diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts
index e8c46afaa1..dfb0f5d017 100644
--- a/packages/spec/src/api/protocol.zod.ts
+++ b/packages/spec/src/api/protocol.zod.ts
@@ -116,15 +116,56 @@ export const GetDiscoveryRequestSchema = lazySchema(() => z.object({}));
* - `routes` provides a flat endpoint map for client routing.
* - `services` is the single source of truth for service availability.
* - `apiName` is kept as an optional alias for `name` for backward compatibility.
- *
+ *
+ * ## This schema is the CLIENT's tolerance, never a producer's licence (#4828)
+ *
+ * `.partial()` exists so a client can parse an OLDER server's response without
+ * exploding, and zod's default unknown-key strip lets it parse a NEWER one.
+ * That tolerance is correct at this boundary and wrong as a producer contract —
+ * and for a long time it was the only schema anything referenced, so it acted
+ * as both. The result was `declared ≠ enforced` in both directions at once:
+ * `getDiscovery()` never emitted the required `name`/`environment`/`locale` and
+ * still parsed clean, while the runtime dispatcher emitted `features` and
+ * `endpoints` — declared nowhere — and also parsed clean.
+ *
+ * So the two schemas now have separate jobs, and a gate compares them:
+ *
+ * - **{@link DiscoverySchema} is authoritative for PRODUCERS.** Every producer's
+ * live shape must satisfy it (`packages/metadata-protocol`, `packages/runtime`
+ * and `packages/rest` each carry a `discovery-schema-conformance.test.ts`).
+ * - **This schema is the CONSUMER's parse**, and its key set is the allowance
+ * those producer gates check against — i.e. `DiscoverySchema`'s keys plus the
+ * declared deprecated aliases. `./discovery.test.ts` pins that equivalence, so
+ * a key can never again appear on one side only.
+ *
+ * ## `apiName` retirement schedule (ADR-0087)
+ *
+ * `apiName` is the sole surviving deprecated alias here. `name` is canonical and
+ * REQUIRED by `DiscoverySchema`; as of protocol 17 every producer emits `name`,
+ * and `getDiscovery()` additionally emits `apiName` with the identical value so
+ * clients pinned to the alias keep working.
+ *
+ * - **Protocol 17 (now)**: both emitted; `name` canonical, `apiName` deprecated.
+ * - **Protocol 18**: producers stop emitting `apiName` and it is removed from
+ * this schema. Consumers migrate to `name` — a pure rename with no semantic
+ * change, which is why it needs no D2 conversion entry (that table converts
+ * AUTHORED metadata at load; a response payload has no load seam).
+ *
+ * Consumers to migrate before 18 — measured 2026-08-05 across `objectstack`,
+ * `objectui` and `cloud`: `packages/client/tests/integration/01-discovery.test.ts`
+ * (TC-DISC-001/002). No product code in any of the three repos reads `apiName`.
+ *
* @see DiscoverySchema in ./discovery.zod.ts — the canonical definition.
*/
export const GetDiscoveryResponseSchema = lazySchema(() => DiscoverySchema
.partial()
.required({ version: true })
.extend({
- /** @deprecated Use `name` instead. Kept for backward compatibility. */
- apiName: z.string().optional().describe('API name (deprecated — use name)'),
+ /**
+ * @deprecated Use `name` instead. Removed in protocol 18 — see the
+ * retirement schedule above. Emitted alongside `name` until then.
+ */
+ apiName: z.string().optional().describe('API name (deprecated — use `name`; removed in protocol 18)'),
}));
/**