diff --git a/.changeset/unified-capability-vocabulary.md b/.changeset/unified-capability-vocabulary.md new file mode 100644 index 0000000000..7e4c26eb07 --- /dev/null +++ b/.changeset/unified-capability-vocabulary.md @@ -0,0 +1,78 @@ +--- +"@objectstack/spec": minor +"@objectstack/runtime": minor +"@objectstack/metadata-protocol": minor +"@objectstack/client": patch +--- + +feat(spec,runtime,metadata-protocol,client)!: one closed capability vocabulary — every discovery producer emits every key (#5672) + +`#4828` renamed the runtime dispatcher's top-level `features` map to the +canonical `capabilities`, which collapsed the *spelling* split between the two +discovery producers. It did not touch the deeper one: the two went on filling +**disjoint key sets**. + +| producer | keys it filled | +|:---|:---| +| `getDiscovery()` — `@objectstack/metadata-protocol`, upstream of REST `/discovery` | `comments` `automation` `cron` `search` `export` `chunkedUpload` `transactionalBatch` | +| `getDiscoveryInfo()` — `@objectstack/runtime` dispatcher, `/.well-known/objectstack` | `search` `websockets` `files` `analytics` `ai` `notifications` `i18n` | + +Only `search` overlapped. `DiscoverySchema.capabilities` was an open +`z.record`, so both shapes parsed clean and no gate could see the split — while +`packages/client`'s `capabilities` getter **asserted** the result was a +`WellKnownCapabilities`. Against a dispatcher-served host +`client.capabilities.transactionalBatch` was therefore statically `boolean` and +actually `undefined`, as were `comments`, `cron`, `export` and `chunkedUpload`. + +Per the maintainer's 2026-08-06 ruling, the vocabulary is now closed and +mandatory. + +**What a consumer sees.** Before: which capability flags exist depended on +which kind of host answered, and a flag you were typed to receive could simply +be missing. After: every discovery response carries **every** flag, always a +boolean. A capability the host does not deliver is `enabled: false` — never an +absent key — so a client can read a flag without knowing whether it reached a +dispatcher, the REST endpoint, or anything else. `client.capabilities` no longer +asserts its own return type: it enumerates the spec's key list, so the type is +true by construction, and it reads a key an older server omits as `false` +(fail-closed, matching the wire rule). + +**`@objectstack/spec`.** `WellKnownCapabilitiesSchema` becomes the one +vocabulary and gains the six flags that were previously the dispatcher's alone +(`websockets`, `files`, `analytics`, `ai`, `notifications`, `i18n`) — all six +were already real answers on the wire, so this declares them rather than +inventing them. `DiscoverySchema.capabilities` changes from an optional open +record to a **required closed object** derived from that vocabulary, one entry +per key. New exports: `WELL_KNOWN_CAPABILITY_KEYS` (the key list, derived from +the schema so nothing can hand-list a fourth dialect) and +`CapabilityDescriptorSchema` / `CapabilityDescriptor` (the `enabled` + +optional `features` / `description` entry shape, previously inline). + +Required, not optional, is the `scoping` precedent read the other way round: +`scoping` is optional because only one producer can honestly answer it, whereas +every producer can answer `capabilities` — and an optional block would leave a +consumer back at `undefined` for every flag. + +**Producers.** Each answers all thirteen keys from its own facts, with the basis +recorded per key in the code. The dispatcher now measures `comments` off the +`sys_comment` object in the registry it already resolves for its `/data` domain, +and `automation` / `cron` / `export` / `chunkedUpload` off the same service +predicates that gate its route advertisements. Its one honest `false` is +`transactionalBatch`: the atomic cross-object `/batch` route is mounted by +`@objectstack/rest`, and this dispatcher has no batch branch at all, so claiming +the runtime's `transaction()` here would advertise an endpoint the host does not +serve. `getDiscovery()` answers the six new flags off the service registry it +already reads, gated on serveability so a self-declared stub does not advertise +a capability it cannot back. + +**Gates.** The three `discovery-schema-conformance.test.ts` suites built by +`#5682` and extended to `routes` by `#5743` gain a fullness criterion — every +vocabulary key present, every `enabled` a real boolean, no key outside the +vocabulary — with the allowance derived from the schema rather than written out. + +**Upgrading.** A producer or fixture that builds a `DiscoverySchema`-shaped +document must now include a complete `capabilities` block; build it from +`WELL_KNOWN_CAPABILITY_KEYS` rather than by hand. Consumers need no change: +they receive strictly more keys than before, and any flag they already read +keeps its meaning. The lenient wire wrapper `GetDiscoveryResponseSchema` still +allows the block to be absent, so a response from an older server still parses. diff --git a/content/docs/references/api/discovery.mdx b/content/docs/references/api/discovery.mdx index d99d2fbdba..badd228346 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, DiscoveryEnvironmentSchema, RouteHealthEntrySchema, RouteHealthReportSchema, ServiceInfoSchema, ServiceSelfInfoSchema, ServiceStatus, WellKnownCapabilitiesSchema } from '@objectstack/spec/api'; -import type { ApiRoutes, DiscoveryEnvironment, RouteHealthEntry, RouteHealthReport, ServiceInfo, ServiceSelfInfo, ServiceStatus, WellKnownCapabilities } from '@objectstack/spec/api'; +import { ApiRoutesSchema, CapabilityDescriptorSchema, DiscoverySchema, DiscoveryEnvironmentSchema, RouteHealthEntrySchema, RouteHealthReportSchema, ServiceInfoSchema, ServiceSelfInfoSchema, ServiceStatus, WellKnownCapabilitiesSchema } from '@objectstack/spec/api'; +import type { ApiRoutes, CapabilityDescriptor, DiscoveryEnvironment, RouteHealthEntry, RouteHealthReport, ServiceInfo, ServiceSelfInfo, ServiceStatus, WellKnownCapabilities } from '@objectstack/spec/api'; // Validate data const result = ApiRoutesSchema.parse(data); @@ -60,6 +60,19 @@ const result = ApiRoutesSchema.parse(data); | **mcp** | `string` | optional | e.g. /api/v1/mcp — always the unscoped base; absent when MCP is disabled or unserveable | +--- + +## CapabilityDescriptor + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | ✅ | Whether this capability is available | +| **features** | `Record` | optional | Sub-feature flags within this capability | +| **description** | `string` | optional | Human-readable capability description | + + --- ## Discovery @@ -74,7 +87,7 @@ const result = ApiRoutesSchema.parse(data); | **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 | +| **capabilities** | `{ comments: object; automation: object; cron: object; search: object; … }` | ✅ | Hierarchical capability descriptors — the full WellKnownCapabilities vocabulary, every key present | | **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 | @@ -189,6 +202,12 @@ Well-known capability flags for frontend intelligent adaptation | **export** | `boolean` | ✅ | Whether the backend supports async export | | **chunkedUpload** | `boolean` | ✅ | Whether the backend supports chunked (multipart) uploads | | **transactionalBatch** | `boolean` | ✅ | Whether the backend exposes the atomic cross-object batch endpoint (POST `{basePath}`/batch, #1604/ADR-0034): all ops commit or roll back together in one transaction. Lets clients skip non-atomic client-side simulation instead of runtime-probing 404/405/501. True ⟺ the /batch route is mounted AND the runtime can honour a transaction. | +| **websockets** | `boolean` | ✅ | Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. False while realtime is an in-process bus with no mounted HTTP/WS surface (ADR-0076 D12, #2462). | +| **files** | `boolean` | ✅ | Whether a file-storage surface (upload/download/attachments) is served | +| **analytics** | `boolean` | ✅ | Whether the backend serves the analytics / BI query surface | +| **ai** | `boolean` | ✅ | Whether the backend serves the AI surface (NLQ, chat, agents, suggest) | +| **notifications** | `boolean` | ✅ | Whether the backend serves the notification surface (inbox, delivery) | +| **i18n** | `boolean` | ✅ | Whether the backend serves the i18n surface (translations, locale negotiation) | --- diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index a332e6d01f..d64a35e40d 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -652,7 +652,7 @@ Enable package response | **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 | +| **capabilities** | `{ comments: object; automation: object; cron: object; search: object; … }` | optional | Hierarchical capability descriptors — the full WellKnownCapabilities vocabulary, every key present | | **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 | 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 de74a738fc..65d5b02e66 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -262,7 +262,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 77 | -| `api/` | 394 | +| `api/` | 395 | | `cloud/` | 82 | | `identity/` | 33 | | `integration/` | 10 | diff --git a/packages/client/src/capabilities-vocabulary.test.ts b/packages/client/src/capabilities-vocabulary.test.ts new file mode 100644 index 0000000000..890561a74e --- /dev/null +++ b/packages/client/src/capabilities-vocabulary.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#5672] The SDK's capability type lie, made falsifiable. +// +// `client.capabilities` is declared `WellKnownCapabilities | undefined` and used +// to reach that type by assertion: +// +// return result as unknown as WellKnownCapabilities; +// +// …over an object built from whatever keys the SERVER happened to send. The two +// discovery producers sent disjoint key sets (#5672), so against a +// dispatcher-served host `client.capabilities.transactionalBatch` was statically +// `boolean` and actually `undefined` — and `comments` / `cron` / `export` / +// `chunkedUpload` with it. +// +// Note WHY this file is a runtime probe and not a `tsc` one: the lie was inside +// a type ASSERTION, so `pnpm typecheck` was green before the fix and is green +// after it. A compiler cannot falsify a cast — only running the getter against +// a real producer's payload can. Each test below therefore pairs the static +// promise (a `const x: boolean = …` binding, which only compiles because the +// declared type says so) with the runtime fact it used to contradict. + +import { describe, it, expect, vi } from 'vitest'; +import { WELL_KNOWN_CAPABILITY_KEYS } from '@objectstack/spec/api'; +import { ObjectStackClient } from './index'; + +/** + * The EXACT `capabilities` map the runtime dispatcher emitted between #4828 and + * #5672 — seven keys, six of which the vocabulary did not contain, and none of + * the seven `WellKnownCapabilities` keys that a consumer was typed to expect. + */ +const DISPATCHER_SHAPE_BEFORE_5672 = { + search: { enabled: true }, + websockets: { enabled: false }, + files: { enabled: false }, + analytics: { enabled: false }, + ai: { enabled: false }, + notifications: { enabled: false }, + i18n: { enabled: false }, +}; + +async function connectedTo(capabilities: unknown) { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: 'v1', name: 'ObjectOS', capabilities }), + }); + const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchImpl as any }); + await client.connect(); + return client; +} + +describe('[#5672] client.capabilities is the whole vocabulary, honestly typed', () => { + it('the probe: `transactionalBatch` is a real boolean against a dispatcher-shaped payload', async () => { + const client = await connectedTo(DISPATCHER_SHAPE_BEFORE_5672); + const caps = client.capabilities!; + + // The static half. This binding compiles only because the declared type + // promises `boolean` — it is the promise under test, written out so the + // reader can see there is nothing else holding it up. + const promisedBoolean: boolean = caps.transactionalBatch; + + // The runtime half. Before the fix this was `undefined`: the getter copied + // the server's key set, and this producer had no such key. `typeof` is the + // assertion that catches it — `toBe(false)` alone would read "the backend + // says no" and pass for a payload that says nothing at all. + expect(typeof promisedBoolean).toBe('boolean'); + expect(promisedBoolean).toBe(false); + }); + + it('every flag the type declares is present and boolean, whichever producer answered', async () => { + const client = await connectedTo(DISPATCHER_SHAPE_BEFORE_5672); + const caps = client.capabilities! as unknown as Record; + + const notBoolean = WELL_KNOWN_CAPABILITY_KEYS.filter(k => typeof caps[k] !== 'boolean'); + expect(notBoolean, 'declared capability flags that are not booleans at runtime').toEqual([]); + + // …and the dispatcher's own answers still come through unchanged. + expect(caps.search).toBe(true); + expect(caps.websockets).toBe(false); + }); + + it('reads a key the server omits as `false` — fail-closed, matching the wire rule', async () => { + // A server that predates the vocabulary. Ruling A says an undelivered + // capability is `enabled: false`; a key that never arrives is read the same + // way, so a consumer skips the feature rather than calling an endpoint that + // may not exist. + const client = await connectedTo({ search: { enabled: true } }); + const caps = client.capabilities!; + + expect(caps.search).toBe(true); + expect(caps.chunkedUpload).toBe(false); + expect(caps.comments).toBe(false); + }); + + it('normalizes the flat boolean form as well as the hierarchical one', async () => { + // Both shapes have been on the wire; the getter reads one bit from either. + const client = await connectedTo({ ...DISPATCHER_SHAPE_BEFORE_5672, comments: true, cron: false }); + + expect(client.capabilities!.comments).toBe(true); + expect(client.capabilities!.cron).toBe(false); + }); + + it('does NOT coerce an off-spec value into a capability claim', async () => { + // `'yes'` / `1` are not booleans on a machine-readable surface. Coercing + // them would fossilise a second dialect in the consumer, which is exactly + // what Prime Directive #12 forbids — the producer's conformance gate is + // where that payload gets called out, not here. + const client = await connectedTo({ + ...DISPATCHER_SHAPE_BEFORE_5672, + comments: 'yes', + cron: { enabled: 1 }, + }); + + expect(client.capabilities!.comments).toBe(false); + expect(client.capabilities!.cron).toBe(false); + }); + + it('exposes exactly the vocabulary — a key outside it never reaches the caller', async () => { + const client = await connectedTo({ ...DISPATCHER_SHAPE_BEFORE_5672, feed: { enabled: true } }); + + expect(Object.keys(client.capabilities!).sort()) + .toEqual([...WELL_KNOWN_CAPABILITY_KEYS].sort()); + expect(client.capabilities!).not.toHaveProperty('feed'); + }); + + it('still returns undefined before connect, and for a body carrying no capabilities', async () => { + const offline = new ObjectStackClient({ baseUrl: 'http://localhost:3000' }); + expect(offline.capabilities).toBeUndefined(); + + const client = await connectedTo(undefined); + expect(client.capabilities).toBeUndefined(); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 3829233091..34fee22c5c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -56,6 +56,9 @@ import { GetFieldLabelsResponse, RegisterRequest, WellKnownCapabilities, + // [#5672] A VALUE, not a type: the capability vocabulary's key list, so the + // getter below enumerates the spec's keys instead of the server's. + WELL_KNOWN_CAPABILITY_KEYS, ApiRoutes, ImportRequest, ImportResponse, @@ -456,22 +459,61 @@ export class ObjectStackClient { /** * Well-known capability flags discovered from the server. - * Returns undefined if the client has not yet connected or the server - * did not include capabilities in its discovery response. + * + * Returns `undefined` only when the client has not connected (or the server + * returned no `capabilities` block at all). Otherwise **every** flag in the + * vocabulary is present and boolean — see below. * * The server may return capabilities in hierarchical format * `{ key: { enabled: boolean } }` or flat boolean format `{ key: boolean }`. * This getter normalizes both to flat `WellKnownCapabilities`. + * + * ## [#5672] The type used to lie; now it does not + * + * This getter copied whatever keys the server happened to send and then + * ASSERTED the result was a `WellKnownCapabilities` + * (`result as unknown as WellKnownCapabilities`). The two discovery + * producers filled disjoint key sets, so against a dispatcher-served host + * `client.capabilities.transactionalBatch` was statically `boolean` and + * actually `undefined` — as were `comments`, `cron`, `export` and + * `chunkedUpload`. Every consumer that trusted the type got `undefined` + * where it had been promised a boolean. + * + * The fix is not a wider return type: it is to stop copying the server's key + * set. This iterates {@link WELL_KNOWN_CAPABILITY_KEYS} — the vocabulary + * derived from `WellKnownCapabilitiesSchema` itself — so the returned object + * has exactly the declared keys, all boolean, BY CONSTRUCTION. The assertion + * is gone because there is nothing left to assert. Add a key to the spec and + * this getter reports it with no edit here. + * + * Two deliberate reading rules: + * + * * **A key the server omits reads `false`**, matching the wire contract's + * own rule (ruling A: an undelivered capability is `enabled: false`). Since + * protocol 18 every conforming producer sends every key, so this only + * applies to a server that predates the vocabulary — and for a capability + * flag, "assume absent" is the fail-closed direction: a consumer skips the + * feature instead of calling an endpoint that may not exist. + * * **Only a real `true` counts.** A non-boolean (`"yes"`, `1`) is off-spec + * on a machine-readable surface, and coercing it would fossilise a second + * dialect in the consumer — exactly the tolerance Prime Directive #12 + * forbids. It reads `false` and the producer's conformance gate is the + * place that says so out loud. */ get capabilities(): WellKnownCapabilities | undefined { const raw = this.discoveryInfo?.capabilities; if (!raw) return undefined; - // Normalize: hierarchical { enabled: boolean } → flat boolean - const result: Record = {}; - for (const [key, value] of Object.entries(raw)) { - result[key] = typeof value === 'object' && value !== null ? !!(value as any).enabled : !!value; + const source = raw as Record; + // Seeded empty and filled from the vocabulary's own key list, which is why + // the result really is a complete `WellKnownCapabilities` when the loop ends. + const flags = {} as WellKnownCapabilities; + for (const key of WELL_KNOWN_CAPABILITY_KEYS) { + const value = source[key]; + flags[key] = typeof value === 'object' && value !== null + ? (value as { enabled?: unknown }).enabled === true + : value === true; } - return result as unknown as WellKnownCapabilities; + return flags; } /** diff --git a/packages/metadata-protocol/src/discovery-schema-conformance.test.ts b/packages/metadata-protocol/src/discovery-schema-conformance.test.ts index c4d9901a91..20c7ec36f0 100644 --- a/packages/metadata-protocol/src/discovery-schema-conformance.test.ts +++ b/packages/metadata-protocol/src/discovery-schema-conformance.test.ts @@ -30,7 +30,12 @@ // from becoming a third dialect of the contract. import { describe, it, expect } from 'vitest'; -import { ApiRoutesSchema, DiscoverySchema, GetDiscoveryResponseSchema } from '@objectstack/spec/api'; +import { + ApiRoutesSchema, + DiscoverySchema, + GetDiscoveryResponseSchema, + WELL_KNOWN_CAPABILITY_KEYS, +} from '@objectstack/spec/api'; import { ObjectStackProtocolImplementation } from './index.js'; /** The keys the protocol declares for a discovery response (canonical + declared alias). */ @@ -53,6 +58,17 @@ function declaredRouteKeys(): Set { return new Set(Object.keys((ApiRoutesSchema as any).shape)); } +/** + * [#5672] The capability vocabulary, taken from the spec's own key list. + * + * Derived, never hand-listed — same discipline as `declaredResponseKeys` and + * `declaredRouteKeys` above. A gate that spells the vocabulary itself is a + * fourth dialect of the contract and drifts the moment a key is added. + */ +function declaredCapabilityKeys(): Set { + return new Set(WELL_KNOWN_CAPABILITY_KEYS as readonly string[]); +} + /** * A protocol impl over a minimal engine. `getDiscovery()` reads * `engine.registry` (for `sys_comment`), `engine.transaction` (for @@ -108,6 +124,62 @@ describe('[#4828] getDiscovery() conforms to DiscoverySchema', () => { expect(declaredRouteKeys().has('mcp')).toBe(true); }); + // ═════════════════════════════════════════════════════════════════════════ + // [#5672] Fullness: the vocabulary, whole, from every producer + // ═════════════════════════════════════════════════════════════════════════ + // + // The #4828 gate above judges the shape; #5679 extended it one level into + // `routes`. This is the same move into `capabilities`, plus the one criterion + // neither of those needed: COMPLETENESS. `routes.mcp` is legitimately absent + // from a producer that cannot know it — a capability never is, because ruling + // A gives "cannot deliver" a spelling of its own (`enabled: false`). + // + // Three criteria, deliberately separate questions: + // 1. every vocabulary key present — the split this issue is about; + // 2. every `enabled` a real boolean — no truthy string / undefined slipping + // through as a capability claim; + // 3. no key outside the vocabulary — the KEY question `safeParse` cannot + // answer, because a zod object strips unknown keys (the #5679 lesson). + describe('[#5672] the capability vocabulary is emitted in full', () => { + it('emits EVERY declared capability key — undelivered means `enabled: false`, never absent', async () => { + const discovery: any = await makeImpl().getDiscovery(); + + const missing = [...declaredCapabilityKeys()].filter( + k => !Object.prototype.hasOwnProperty.call(discovery.capabilities, k), + ); + expect(missing, 'capability keys the getDiscovery() shape fails to emit').toEqual([]); + }); + + it('reports every capability with a boolean `enabled`', async () => { + const discovery: any = await makeImpl().getDiscovery(); + + const nonBoolean = Object.entries(discovery.capabilities as Record) + .filter(([, v]) => typeof v?.enabled !== 'boolean') + .map(([k, v]) => `${k}: ${typeof v?.enabled}`); + expect(nonBoolean, 'capability entries whose `enabled` is not a boolean').toEqual([]); + }); + + it('emits NO capability key the vocabulary does not declare', async () => { + const discovery: any = await makeImpl().getDiscovery(); + + const declared = declaredCapabilityKeys(); + const undeclared = Object.keys(discovery.capabilities).filter(k => !declared.has(k)); + expect(undeclared, 'undeclared keys inside `capabilities` on the getDiscovery() shape').toEqual([]); + }); + + it('anti-vacuity: the vocabulary really does span BOTH producers\' historical halves', async () => { + const discovery: any = await makeImpl().getDiscovery(); + + // Without this the three gates above would pass on a vocabulary that had + // quietly shrunk back to one producer's half. `websockets` was the + // dispatcher's alone before #5672 and `transactionalBatch` this + // builder's; both must now be answered here. + expect(discovery.capabilities.transactionalBatch.enabled).toBe(false); // no transaction() on this stub engine + expect(discovery.capabilities.websockets.enabled).toBe(false); + expect(declaredCapabilityKeys().size).toBeGreaterThanOrEqual(13); + }); + }); + it('carries the canonical `name`, and keeps `apiName` for its deprecation window', async () => { const discovery: any = await makeImpl().getDiscovery(); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index fd5fbd860b..843d9367ff 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -21,7 +21,7 @@ import type { InstallPackageRequest, InstallPackageResponse } from '@objectstack/spec/api'; -import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api'; +import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities, CapabilityDescriptor } from '@objectstack/spec/api'; import type { ApiError, BatchOperationResult } from '@objectstack/spec/api'; import { readServiceSelfInfo, ErrorCode, standardErrorCodeForHttpStatus, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; import { @@ -2771,8 +2771,23 @@ export class ObjectStackProtocolImplementation implements }; // Build well-known capabilities from registered services. - // DiscoverySchema defines capabilities as Record - // (hierarchical format). We also keep a flat WellKnownCapabilities for backward compat. + // + // [#5672] `WellKnownCapabilitiesSchema` is THE capability vocabulary and + // `DiscoverySchema.capabilities` is now a closed object over it, so this + // literal must answer EVERY key — a capability this host does not + // deliver is `enabled: false`, never an absent key (maintainer ruling A, + // 2026-08-06). The `WellKnownCapabilities` annotation is what enforces + // that at compile time: adding a key to the vocabulary breaks this line + // until it is answered here. + // + // Each key's basis is recorded next to it. Where a capability is backed + // by a service slot, the predicate is deliberately the SAME one that + // decides whether the route is advertised (`advertisedRoute`/ + // `unserveable` above) — what we advertise and what we claim cannot + // disagree. + const capabilityServed = (serviceName: string) => + registeredServices.has(serviceName) && !unserveable(serviceName); + const wellKnown: WellKnownCapabilities = { // Comments/chatter are served by the `sys_comment` object via the generic // data API (ADR-0052 §5) — not a dedicated service. The capability is true @@ -2783,7 +2798,18 @@ export class ObjectStackProtocolImplementation implements cron: registeredServices.has('job'), search: registeredServices.has('search'), export: registeredServices.has('automation') || registeredServices.has('queue'), - chunkedUpload: registeredServices.has('file-storage'), + // [#5672] Serveability-gated, was presence-only. Two reasons, and + // the second is the binding one: + // 1. `declared === enforced` — a self-declared stub file-storage + // mounts no HTTP surface, so this builder already withholds + // `routes.storage` from it; advertising chunked upload anyway + // promised an upload endpoint that cannot exist. + // 2. the runtime dispatcher answers this key `hasFiles`, i.e. + // `isServiceServeable(filesSvc)`. Leaving this one on presence + // would make the two producers give the SAME host opposite + // answers for the SAME key — a new dialect inside the + // vocabulary this issue exists to unify. + chunkedUpload: capabilityServed('file-storage'), // Atomic cross-object batch (#3298 / #1604 / ADR-0034 item 4): the // REST /batch endpoint runs its ops inside `engine.transaction()`, // which only opens a real (all-or-nothing) transaction when the @@ -2795,12 +2821,45 @@ export class ObjectStackProtocolImplementation implements // (ADR-0119 D1: `transaction` is contract-declared, so this probe // no longer needs a structural cast to ask the question.) transactionalBatch: typeof this.engine?.transaction === 'function', + + // ── Joined the vocabulary with ruling A (#5672) ─────────────────── + // These six used to be the runtime dispatcher's half of the split. + // This builder can answer all of them from the services registry it + // already reads, so none of them is a "cannot deliver ⇒ false" + // placeholder — they are measured, per key: + + // No host mounts a WS/SSE surface: service-realtime is an in-process + // pub/sub bus, which is precisely why SERVICE_CONFIG.realtime + // declares `noHttpSurface` and no `routes.realtime` is ever + // advertised (ADR-0076 D12, #2462). A literal `false` is the honest + // answer here, not a stand-in for one — and it matches the runtime + // dispatcher's answer for the same reason, in the same words. + websockets: false, + // Storage: the `file-storage` slot, gated on serveability rather + // than presence — a self-declared stub mounts nothing, and this + // builder already withholds `routes.storage` from it. + files: capabilityServed('file-storage'), + analytics: capabilityServed('analytics'), + ai: capabilityServed('ai'), + // Slot is `notification` (singular, CoreServiceName); the capability + // and the route key are plural. Same slot, three spellings — the + // mapping is here so nothing has to guess it. + notifications: capabilityServed('notification'), + i18n: capabilityServed('i18n'), }; - // Convert flat booleans → hierarchical capability objects - const capabilities: Record = {}; - for (const [key, enabled] of Object.entries(wellKnown)) { - capabilities[key] = { enabled }; + // Convert flat booleans → hierarchical capability objects. + // + // [#5672] Keyed by the vocabulary, not by `string`. The old + // `Record` was assignable to the open record + // `DiscoverySchema.capabilities` used to be; against the closed shape + // it no longer is, and that is the closure doing its job — the compiler + // now refuses a producer whose capability map is not the whole + // vocabulary. Iterating `wellKnown`'s own keys means fullness is + // carried over from the annotated literal above rather than re-asserted. + const capabilities = {} as Record; + for (const key of Object.keys(wellKnown) as Array) { + capabilities[key] = { enabled: wellKnown[key] }; } // [#4828] Locale, derived from the registered i18n service exactly the diff --git a/packages/rest/src/discovery-schema-conformance.test.ts b/packages/rest/src/discovery-schema-conformance.test.ts index b0684ceede..ef80ad102c 100644 --- a/packages/rest/src/discovery-schema-conformance.test.ts +++ b/packages/rest/src/discovery-schema-conformance.test.ts @@ -16,7 +16,12 @@ // undeclared. import { describe, it, expect, vi } from 'vitest'; -import { ApiRoutesSchema, DiscoverySchema, GetDiscoveryResponseSchema } from '@objectstack/spec/api'; +import { + ApiRoutesSchema, + DiscoverySchema, + GetDiscoveryResponseSchema, + WELL_KNOWN_CAPABILITY_KEYS, +} from '@objectstack/spec/api'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server.js'; @@ -43,6 +48,11 @@ function declaredRouteKeys(): Set { return new Set(Object.keys((ApiRoutesSchema as any).shape)); } +/** [#5672] The capability vocabulary, derived from the spec — never hand-listed. */ +function declaredCapabilityKeys(): Set { + return new Set(WELL_KNOWN_CAPABILITY_KEYS as readonly string[]); +} + function createMockServer() { return { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), @@ -194,6 +204,54 @@ describe('[#4828] the REST /discovery live shape conforms to DiscoverySchema', ( expect(DiscoverySchema.safeParse(body).success).toBe(true); }); + // ═════════════════════════════════════════════════════════════════════════ + // [#5672] Fullness — on the COMPOSED shape, which is the one a browser gets + // ═════════════════════════════════════════════════════════════════════════ + // + // This producer composes over `getDiscovery()` and then overwrites exactly + // one capability entry (`transactionalBatch`, ANDed with `api.enableBatch`). + // So the vocabulary reaches the wire through it, and the one entry it + // rewrites is the one most at risk of being rewritten into a different shape + // — `caps.transactionalBatch = { enabled, description }` is a whole-entry + // assignment, not a merge. + describe('[#5672] the capability vocabulary survives composition, in full', () => { + it('emits EVERY declared capability key on the composed body', async () => { + const body = await invoke(discoveryHandler()); + + const missing = [...declaredCapabilityKeys()].filter( + k => !Object.prototype.hasOwnProperty.call(body.capabilities, k), + ); + expect(missing, 'capability keys the composed REST /discovery body fails to emit').toEqual([]); + }); + + it('reports every capability with a boolean `enabled`', async () => { + const body = await invoke(discoveryHandler()); + + const nonBoolean = Object.entries(body.capabilities as Record) + .filter(([, v]) => typeof v?.enabled !== 'boolean') + .map(([k, v]) => `${k}: ${typeof v?.enabled}`); + expect(nonBoolean, 'capability entries whose `enabled` is not a boolean').toEqual([]); + }); + + it('emits NO capability key the vocabulary does not declare', async () => { + const body = await invoke(discoveryHandler()); + + const declared = declaredCapabilityKeys(); + const undeclared = Object.keys(body.capabilities).filter(k => !declared.has(k)); + expect(undeclared, 'undeclared keys inside `capabilities` on the composed body').toEqual([]); + }); + + it("the REST layer's own AND rewrites `transactionalBatch` without dropping it out of the vocabulary", async () => { + const body = await invoke(discoveryHandler()); + + // Anti-vacuity for the three above: the composition really does run here + // (the entry carries the REST layer's description, which `getDiscovery()` + // never attaches), and it still lands as a well-formed vocabulary entry. + expect(typeof body.capabilities.transactionalBatch.enabled).toBe('boolean'); + expect(body.capabilities.transactionalBatch.description).toMatch(/Atomic cross-object batch/); + }); + }); + it('keeps `capabilities` as the one capability key — no `features`, no `endpoints`', async () => { const body = await invoke(discoveryHandler()); diff --git a/packages/runtime/src/discovery-schema-conformance.test.ts b/packages/runtime/src/discovery-schema-conformance.test.ts index cced5e7f5c..3dd104b5c5 100644 --- a/packages/runtime/src/discovery-schema-conformance.test.ts +++ b/packages/runtime/src/discovery-schema-conformance.test.ts @@ -15,7 +15,12 @@ // contrived fixture needed. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ApiRoutesSchema, DiscoverySchema, GetDiscoveryResponseSchema } from '@objectstack/spec/api'; +import { + ApiRoutesSchema, + DiscoverySchema, + GetDiscoveryResponseSchema, + WELL_KNOWN_CAPABILITY_KEYS, +} from '@objectstack/spec/api'; import { HttpDispatcher } from './http-dispatcher.js'; /** The keys the protocol declares for a discovery response (canonical + declared alias). */ @@ -38,6 +43,11 @@ function declaredRouteKeys(): Set { return new Set(Object.keys((ApiRoutesSchema as any).shape)); } +/** [#5672] The capability vocabulary, derived from the spec — never hand-listed. */ +function declaredCapabilityKeys(): Set { + return new Set(WELL_KNOWN_CAPABILITY_KEYS as readonly string[]); +} + describe('[#4828] getDiscoveryInfo() conforms to DiscoverySchema', () => { let dispatcher: HttpDispatcher; @@ -122,6 +132,109 @@ describe('[#4828] getDiscoveryInfo() conforms to DiscoverySchema', () => { expect(withoutMcp.routes.mcp).toBeUndefined(); }); + // ═════════════════════════════════════════════════════════════════════════ + // [#5672] Fullness: the vocabulary, whole, from every producer + // ═════════════════════════════════════════════════════════════════════════ + // + // The sibling gate in `packages/metadata-protocol` carries the full reasoning. + // This producer is the one that owned the OTHER half of the split: before + // ruling A it emitted `search`/`websockets`/`files`/`analytics`/`ai`/ + // `notifications`/`i18n` and nothing else, so `client.capabilities + // .transactionalBatch` was statically `boolean` and actually `undefined` + // against any dispatcher-served host. + describe('[#5672] the capability vocabulary is emitted in full', () => { + it('emits EVERY declared capability key — undelivered means `enabled: false`, never absent', async () => { + const info: any = await dispatcher.getDiscoveryInfo('/api/v1'); + + const missing = [...declaredCapabilityKeys()].filter( + k => !Object.prototype.hasOwnProperty.call(info.capabilities, k), + ); + expect(missing, 'capability keys the getDiscoveryInfo() shape fails to emit').toEqual([]); + }); + + it('reports every capability with a boolean `enabled`', async () => { + const info: any = await dispatcher.getDiscoveryInfo('/api/v1'); + + const nonBoolean = Object.entries(info.capabilities as Record) + .filter(([, v]) => typeof v?.enabled !== 'boolean') + .map(([k, v]) => `${k}: ${typeof v?.enabled}`); + expect(nonBoolean, 'capability entries whose `enabled` is not a boolean').toEqual([]); + }); + + it('emits NO capability key the vocabulary does not declare', async () => { + const info: any = await dispatcher.getDiscoveryInfo('/api/v1'); + + const declared = declaredCapabilityKeys(); + const undeclared = Object.keys(info.capabilities).filter(k => !declared.has(k)); + expect(undeclared, 'undeclared keys inside `capabilities` on the getDiscoveryInfo() shape').toEqual([]); + }); + + it('anti-vacuity: the six keys this producer never used to emit are really answered', async () => { + const info: any = await dispatcher.getDiscoveryInfo('/api/v1'); + + // The metadata-protocol half of the old split, measured on THIS producer. + for (const key of ['comments', 'automation', 'cron', 'export', 'chunkedUpload', 'transactionalBatch'] as const) { + expect(info.capabilities[key], `capabilities.${key}`).toBeDefined(); + expect(typeof info.capabilities[key].enabled, `capabilities.${key}.enabled`).toBe('boolean'); + } + + // `comments` is TRUE here, and that is the anti-vacuity that matters: + // this suite's kernel stubs `registry.getObject` to answer every name, so + // `sys_comment` resolves. A hardcoded `false` — the shape ruling A allows + // for a capability a producer cannot deliver — would read `false` here, + // so this one assertion is what proves the key is MEASURED rather than + // stamped. Its `false` counterpart is the dedicated test below. + expect(info.capabilities.comments.enabled).toBe(true); + + // The rest are genuinely absent on a kernel with no services registered. + for (const key of ['automation', 'cron', 'export', 'chunkedUpload', 'transactionalBatch'] as const) { + expect(info.capabilities[key].enabled, `capabilities.${key}.enabled`).toBe(false); + } + }); + + it('answers `comments` from the registry it can actually reach, not from a hardcoded false', async () => { + // Ruling A point 3 says an undeliverable capability is `false` — but it + // does NOT license answering `false` for a capability the producer CAN + // compute. This dispatcher resolves `objectql` for its own data domain, + // and `/data/sys_comment` is exactly how comments are served (ADR-0052 + // §5), so the honest answer tracks the object's presence — the same + // derivation `getDiscovery()` uses, from this producer's own kernel face. + const kernel = { + context: { + getService: (name: string) => { + if (name === 'objectql') { + return { + registry: { + getObject: (n: string) => (n === 'sys_comment' ? { name: 'sys_comment' } : undefined), + getRegisteredTypes: () => [], + getAllPackages: () => [], + }, + }; + } + return null; + }, + }, + } as any; + + const info: any = await new HttpDispatcher(kernel).getDiscoveryInfo('/api/v1'); + expect(info.capabilities.comments.enabled).toBe(true); + expect(DiscoverySchema.safeParse(info).success).toBe(true); + + // …and the other direction: a registry WITHOUT `sys_comment` answers + // false. Both halves, so neither a stamped `true` nor a stamped `false` + // could pass this pair. + const withoutComments = { + context: { + getService: (name: string) => (name === 'objectql' + ? { registry: { getObject: () => undefined, getRegisteredTypes: () => [], getAllPackages: () => [] } } + : null), + }, + } as any; + const bare: any = await new HttpDispatcher(withoutComments).getDiscoveryInfo('/api/v1'); + expect(bare.capabilities.comments.enabled).toBe(false); + }); + }); + it('has retired `features` and `endpoints` (ADR-0049 enforce-or-remove)', async () => { const info: any = await dispatcher.getDiscoveryInfo('/api/v1'); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 5b650b7386..aa5f500780 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1257,6 +1257,18 @@ export class HttpDispatcher { // Self-description of whatever fills the `metadata` slot (D12, #4089). const metadataSelf = metadataSvc ? readServiceSelfInfo(metadataSvc) : undefined; + // [#5672] Comments/chatter are served by the `sys_comment` object via + // the generic data API (ADR-0052 §5) — `/data/sys_comment` is a + // dispatcher domain, so this producer really does deliver the + // capability and must answer it from what it can measure, not with a + // blanket `false`. `getObjectQLService` is the same accessor the data + // domain resolves its engine through, so the advertisement and the + // service agree by construction. Same derivation as `getDiscovery()`, + // reached through this producer's own kernel face. + const objectqlSvc = await this.getObjectQLService(kernel); + const hasComments = !!(objectqlSvc as { registry?: { getObject?: (n: string) => unknown } } | null) + ?.registry?.getObject?.('sys_comment'); + // Derive locale info from actual i18n service when available let locale = { default: 'en', supported: ['en'], timezone: 'UTC' }; if (hasI18n && i18nSvc) { @@ -1308,6 +1320,18 @@ export class HttpDispatcher { // The hierarchical `{ enabled }` shape is what `capabilities` // declares (and what the `getDiscovery()` producer already emits), // so a client reads ONE shape from either producer. + // + // [#5672] …and now the same KEY SET from either producer. #4828 + // stopped at the spelling: both producers emitted `capabilities`, + // but this one filled `search`/`websockets`/`files`/`analytics`/ + // `ai`/`notifications`/`i18n` while `getDiscovery()` filled + // `comments`/`automation`/`cron`/`search`/`export`/`chunkedUpload`/ + // `transactionalBatch` — disjoint but for `search`, and legal + // because `DiscoverySchema.capabilities` was an open record. Ruling + // A (2026-08-06) closes the vocabulary and requires EVERY key from + // EVERY producer, with `enabled: false` as the spelling for "this + // host does not deliver it". The six additions below are the other + // producer's half, answered from THIS producer's own facts. capabilities: { search: { enabled: hasSearch }, // No WS/HTTP realtime surface is mounted anywhere — a mere @@ -1319,6 +1343,45 @@ export class HttpDispatcher { ai: { enabled: hasAi }, notifications: { enabled: hasNotification }, i18n: { enabled: hasI18n }, + + // ── The `getDiscovery()` half (#5672) ───────────────────────── + // Basis per key, since ruling A's `false` must never be + // confused with "we did not look": + + // MEASURED: the `sys_comment` object in the registry this + // dispatcher resolves for its own `/data` domain (see above). + comments: { enabled: hasComments }, + // MEASURED: the same serveability predicate that gates + // `routes.automation` — a self-declared stub in the slot + // advertises neither the route nor the capability. + automation: { enabled: hasAutomation }, + // MEASURED: the `job` slot. Presence, not serveability, is the + // right test — job is a kernel-INTERNAL contract with no HTTP + // surface by design (#4318), so an occupant is the capability. + cron: { enabled: hasJob }, + // MEASURED: async export is driven by automation or by the + // queue — the same disjunction `getDiscovery()` uses. + export: { enabled: hasAutomation || hasQueue }, + // MEASURED: chunked upload rides the file-storage surface, so + // it is exactly `files` on this host. Two vocabulary keys with + // one answer is a fact about the host (one storage surface + // serving both), not a copy-paste. + chunkedUpload: { enabled: hasFiles }, + // NOT DELIVERED ⇒ false (ruling A point 3), and this is the one + // key here that is a genuine "this face does not serve it" + // rather than a measurement. The atomic cross-object `/batch` + // route is mounted by `@objectstack/rest` + // (`registerBatchEndpoints`) — this dispatcher has no batch + // branch at all: `domains/data.ts` routes only `query` as a + // custom action, and `callData`'s vestigial `action === 'batch'` + // arm is unreachable from here and returns `{ results: [] }` + // without opening a transaction. Answering `engine.transaction` + // instead would advertise atomicity for an endpoint this host + // does not serve — the `declared ≠ enforced` lie the flag was + // introduced (#3298/#1604) to remove. A host that mounts REST + // gets the honest `true` from the REST producer, which ANDs the + // runtime verdict with its own `api.enableBatch`. + transactionalBatch: { enabled: false }, }, services: { // Kernel-provided (always served by the protocol implementation) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 8d72db28e0..3ea70c5c92 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -2431,6 +2431,8 @@ "CacheInvalidationResponse (type)", "CacheInvalidationResponseSchema (const)", "CacheInvalidationTarget (type)", + "CapabilityDescriptor (type)", + "CapabilityDescriptorSchema (const)", "CheckPermissionRequest (type)", "CheckPermissionRequestSchema (const)", "CheckPermissionResponse (type)", @@ -3139,6 +3141,7 @@ "VersioningConfigSchema (const)", "VersioningStrategy (type)", "ViewProtocol (interface)", + "WELL_KNOWN_CAPABILITY_KEYS (const)", "WebSocketConfig (type)", "WebSocketConfigSchema (const)", "WebSocketEvent (type)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 31cd8796ef..eb819733d3 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -672,6 +672,9 @@ "api/CacheInvalidationResponse:invalidated", "api/CacheInvalidationResponse:success", "api/CacheInvalidationResponse:targets", + "api/CapabilityDescriptor:description", + "api/CapabilityDescriptor:enabled", + "api/CapabilityDescriptor:features", "api/CheckPermissionRequest:action", "api/CheckPermissionRequest:field", "api/CheckPermissionRequest:object", @@ -2040,13 +2043,19 @@ "api/WebSocketServerConfig:path", "api/WebSocketServerConfig:presence", "api/WebSocketServerConfig:reconnectAttempts", + "api/WellKnownCapabilities:ai", + "api/WellKnownCapabilities:analytics", "api/WellKnownCapabilities:automation", "api/WellKnownCapabilities:chunkedUpload", "api/WellKnownCapabilities:comments", "api/WellKnownCapabilities:cron", "api/WellKnownCapabilities:export", + "api/WellKnownCapabilities:files", + "api/WellKnownCapabilities:i18n", + "api/WellKnownCapabilities:notifications", "api/WellKnownCapabilities:search", "api/WellKnownCapabilities:transactionalBatch", + "api/WellKnownCapabilities:websockets", "automation/ActionDescriptor:aliasOf", "automation/ActionDescriptor:category", "automation/ActionDescriptor:configSchema", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 70fc145482..df7f1f6224 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -130,6 +130,7 @@ "api/CacheInvalidationRequest", "api/CacheInvalidationResponse", "api/CacheInvalidationTarget", + "api/CapabilityDescriptor", "api/CheckPermissionRequest", "api/CheckPermissionResponse", "api/CodeGenerationTemplate", diff --git a/packages/spec/src/api/discovery.test.ts b/packages/spec/src/api/discovery.test.ts index 74c6db235f..50596c9ce7 100644 --- a/packages/spec/src/api/discovery.test.ts +++ b/packages/spec/src/api/discovery.test.ts @@ -5,6 +5,8 @@ import { ServiceInfoSchema, ServiceStatus, WellKnownCapabilitiesSchema, + WELL_KNOWN_CAPABILITY_KEYS, + CapabilityDescriptorSchema, RouteHealthEntrySchema, RouteHealthReportSchema, ServiceSelfInfoSchema, @@ -95,6 +97,23 @@ const minimalServices = { metadata: { enabled: true, status: 'available' as const, route: '/api/v1/meta', provider: 'objectql' }, }; +/** + * [#5672] The minimal legal `capabilities` block: the WHOLE vocabulary, every + * entry `enabled: false`. + * + * Ruling A made `capabilities` a required, closed map, so every fixture below + * carries one — including the fixtures that exist to be REJECTED, which must + * fail for their own planted defect rather than for a second missing key they + * were never testing. + * + * Built from `WELL_KNOWN_CAPABILITY_KEYS` rather than hand-listed, so adding a + * capability to the spec does not silently leave this file testing a stale + * vocabulary. + */ +const allCapabilitiesOff = Object.fromEntries( + WELL_KNOWN_CAPABILITY_KEYS.map(key => [key, { enabled: false }]), +) as DiscoveryResponse['capabilities']; + describe('DiscoverySchema', () => { it('should accept valid minimal discovery response', () => { const discovery: DiscoveryResponse = { @@ -106,6 +125,7 @@ describe('DiscoverySchema', () => { metadata: '/api/v1/meta', auth: '/api/v1/auth', }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'en-US', @@ -129,6 +149,7 @@ describe('DiscoverySchema', () => { actions: '/api/v1/p', storage: '/api/v1/storage', }, + capabilities: allCapabilitiesOff, services: { ...minimalServices, search: { enabled: true, status: 'available' as const, route: '/api/v1/search', provider: 'plugin-search' }, @@ -157,6 +178,7 @@ describe('DiscoverySchema', () => { metadata: '/api/v1/meta', auth: '/api/v1/auth', }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'en-US', @@ -178,6 +200,7 @@ describe('DiscoverySchema', () => { metadata: '/api/v1/meta', auth: '/api/v1/auth', }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'en-US', @@ -197,6 +220,7 @@ describe('DiscoverySchema', () => { metadata: '/api/v1/meta', auth: '/api/v1/auth', }, + capabilities: allCapabilitiesOff, services: { ...minimalServices, search: { enabled: true, status: 'available' as const, route: '/api/v1/search', provider: 'plugin-search' }, @@ -221,6 +245,7 @@ describe('DiscoverySchema', () => { metadata: '/api/v1/meta', auth: '/api/v1/auth', }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'en-US', @@ -242,6 +267,7 @@ describe('DiscoverySchema', () => { metadata: '/api/v1/meta', auth: '/api/v1/auth', }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'en-US', @@ -263,6 +289,7 @@ describe('DiscoverySchema', () => { metadata: '/api/v1/meta', auth: '/api/v1/auth', }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'zh-CN', @@ -296,6 +323,7 @@ describe('DiscoverySchema', () => { metadata: '/api/v1/meta', auth: '/api/v1/auth', }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'en-US', @@ -317,6 +345,7 @@ describe('DiscoverySchema', () => { metadata: '/api/v1/meta', auth: '/api/v1/auth', }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'en-US', @@ -348,6 +377,7 @@ describe('DiscoverySchema', () => { metadata: '/api/v1/meta', auth: '/api/v1/auth', }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'en-US', @@ -364,6 +394,7 @@ describe('DiscoverySchema', () => { metadata: '/api/v1/meta', auth: '/api/v1/auth', }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'en-US', @@ -382,6 +413,10 @@ describe('DiscoverySchema', () => { data: '/api/v1/data', metadata: '/api/v1/meta', }, + // Present so the ONLY defect under test is the missing `services` — + // without it this would also throw for a missing `capabilities` (#5672) + // and stop testing what its name says. + capabilities: allCapabilitiesOff, locale: { default: 'en-US', supported: ['en-US'], @@ -452,6 +487,7 @@ describe('DiscoverySchema with services', () => { metadata: '/api/v1/meta', auth: '/api/v1/auth', }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'en-US', @@ -589,6 +625,7 @@ describe('DiscoverySchema (capabilities field)', () => { version: '1.0.0', environment: 'production' as const, routes: { data: '/api/v1/data', metadata: '/api/v1/meta' }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'en-US', supported: ['en-US'], timezone: 'UTC' }, }; @@ -596,7 +633,12 @@ describe('DiscoverySchema (capabilities field)', () => { it('should accept discovery with capabilities', () => { const discovery = DiscoverySchema.parse({ ...fixture, + // [#5672] Spread over the full vocabulary rather than replacing it: this + // test is about the hierarchical extras (`features`, `description`) + // round-tripping, and a two-key literal is no longer a legal + // `capabilities` block at all. capabilities: { + ...allCapabilitiesOff, comments: { enabled: true, features: { threaded: true, reactions: true, mentions: true }, @@ -608,14 +650,66 @@ describe('DiscoverySchema (capabilities field)', () => { }, }, }); - expect(discovery.capabilities?.comments.enabled).toBe(true); - expect(discovery.capabilities?.comments.features?.threaded).toBe(true); - expect(discovery.capabilities?.automation.enabled).toBe(false); + expect(discovery.capabilities.comments.enabled).toBe(true); + expect(discovery.capabilities.comments.features?.threaded).toBe(true); + expect(discovery.capabilities.automation.enabled).toBe(false); + }); + + // ── [#5672] ruling A: closed vocabulary, emitted whole ───────────────────── + // + // These four replace the single `should allow capabilities to be omitted` + // test, which pinned exactly the limb this issue removes. Note it would have + // kept PASSING for the wrong reason if it had merely been re-spelled — a + // `toBeUndefined()` on a key nothing produces is green whether the contract + // says "optional" or "we forgot"; only asserting the rejection is evidence. + + it('REJECTS a discovery that omits capabilities entirely', () => { + const { capabilities: _omitted, ...withoutCapabilities } = fixture; + const result = DiscoverySchema.safeParse(withoutCapabilities); + + expect(result.success).toBe(false); + expect( + result.success ? [] : result.error.issues.map(i => i.path.join('.')), + ).toContain('capabilities'); + }); + + it('REJECTS a discovery that emits only part of the vocabulary', () => { + // The exact pre-#5672 shapes: each producer's half, on its own. + const dispatcherHalf = { + search: { enabled: true }, websockets: { enabled: false }, files: { enabled: false }, + analytics: { enabled: false }, ai: { enabled: false }, notifications: { enabled: false }, + i18n: { enabled: false }, + }; + const protocolHalf = { + comments: { enabled: false }, automation: { enabled: false }, cron: { enabled: false }, + search: { enabled: true }, export: { enabled: false }, chunkedUpload: { enabled: false }, + transactionalBatch: { enabled: false }, + }; + + for (const half of [dispatcherHalf, protocolHalf]) { + expect( + DiscoverySchema.safeParse({ ...fixture, capabilities: half }).success, + `a partial capability map must not parse: ${Object.keys(half).join(',')}`, + ).toBe(false); + } }); - it('should allow capabilities to be omitted', () => { - const discovery = DiscoverySchema.parse(fixture); - expect(discovery.capabilities).toBeUndefined(); + it('declares exactly the WellKnownCapabilities vocabulary — one list, not two', () => { + const declared = Object.keys((DiscoverySchema as any).shape.capabilities.shape); + expect(new Set(declared)).toEqual(new Set(WELL_KNOWN_CAPABILITY_KEYS)); + // Anti-vacuity: the vocabulary really is the UNION of the two halves that + // used to be disjoint, not one of them. + expect(declared).toEqual(expect.arrayContaining(['transactionalBatch', 'websockets'])); + }); + + it('every entry carries the CapabilityDescriptor shape', () => { + const parsed = DiscoverySchema.parse(fixture); + for (const key of WELL_KNOWN_CAPABILITY_KEYS) { + expect( + CapabilityDescriptorSchema.safeParse(parsed.capabilities[key]).success, + `capabilities.${key}`, + ).toBe(true); + } }); }); @@ -629,6 +723,7 @@ describe('DiscoverySchema (schemaDiscovery field)', () => { version: '1.0.0', environment: 'production' as const, routes: { data: '/api/v1/data', metadata: '/api/v1/meta' }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'en-US', supported: ['en-US'], timezone: 'UTC' }, }; @@ -666,70 +761,65 @@ describe('DiscoverySchema (schemaDiscovery field)', () => { // ========================================== describe('WellKnownCapabilitiesSchema', () => { + /** Every vocabulary flag set to `value` — built from the vocabulary, never listed. */ + const allFlags = (value: boolean) => + Object.fromEntries(WELL_KNOWN_CAPABILITY_KEYS.map(k => [k, value])) as WellKnownCapabilities; + it('should accept all capabilities enabled', () => { - const caps: WellKnownCapabilities = { - comments: true, - automation: true, - cron: true, - search: true, - export: true, - chunkedUpload: true, - transactionalBatch: true, - }; + const caps: WellKnownCapabilities = allFlags(true); expect(() => WellKnownCapabilitiesSchema.parse(caps)).not.toThrow(); }); it('should accept all capabilities disabled', () => { - const caps = WellKnownCapabilitiesSchema.parse({ - comments: false, - automation: false, - cron: false, - search: false, - export: false, - chunkedUpload: false, - transactionalBatch: false, - }); + const caps = WellKnownCapabilitiesSchema.parse(allFlags(false)); expect(caps.comments).toBe(false); expect(caps.chunkedUpload).toBe(false); expect(caps.transactionalBatch).toBe(false); + // [#5672] The six that joined the vocabulary with ruling A. + expect(caps.websockets).toBe(false); + expect(caps.files).toBe(false); + expect(caps.analytics).toBe(false); + expect(caps.ai).toBe(false); + expect(caps.notifications).toBe(false); + expect(caps.i18n).toBe(false); }); it('should reject missing required fields', () => { expect(() => WellKnownCapabilitiesSchema.parse({ comments: true })).toThrow(); expect(() => WellKnownCapabilitiesSchema.parse({})).toThrow(); - // transactionalBatch is required — a payload missing only it must fail so a - // producer can never silently omit the batch capability bit (#3298). - expect(() => WellKnownCapabilitiesSchema.parse({ - comments: true, - automation: true, - cron: true, - search: true, - export: true, - chunkedUpload: true, - })).toThrow(); + // EVERY key is required — a payload missing exactly one must fail, so no + // producer can silently drop a capability bit (#3298 established this for + // `transactionalBatch`; #5672 makes it the rule for the whole vocabulary). + for (const dropped of WELL_KNOWN_CAPABILITY_KEYS) { + const { [dropped]: _gone, ...rest } = allFlags(true); + expect( + WellKnownCapabilitiesSchema.safeParse(rest).success, + `a payload missing only \`${dropped}\` must be rejected`, + ).toBe(false); + } }); it('should reject non-boolean values', () => { expect(() => WellKnownCapabilitiesSchema.parse({ + ...allFlags(true), comments: 'yes', - automation: true, - cron: true, - search: true, - export: true, - chunkedUpload: true, - transactionalBatch: true, })).toThrow(); }); it('should have .describe() annotations on all fields', () => { - const shape = WellKnownCapabilitiesSchema.shape; - expect(shape.comments.description).toBeDefined(); - expect(shape.automation.description).toBeDefined(); - expect(shape.cron.description).toBeDefined(); - expect(shape.search.description).toBeDefined(); - expect(shape.export.description).toBeDefined(); - expect(shape.chunkedUpload.description).toBeDefined(); - expect(shape.transactionalBatch.description).toBeDefined(); + const shape = WellKnownCapabilitiesSchema.shape as Record; + // Derived, so a new capability cannot arrive undocumented — the reference + // docs are generated from these strings. + const undocumented = WELL_KNOWN_CAPABILITY_KEYS.filter(k => !shape[k]?.description); + expect(undocumented, 'vocabulary keys with no .describe()').toEqual([]); + }); + + it('[#5672] is the ONE vocabulary — WELL_KNOWN_CAPABILITY_KEYS is derived from it', () => { + expect([...WELL_KNOWN_CAPABILITY_KEYS].sort()) + .toEqual(Object.keys(WellKnownCapabilitiesSchema.shape).sort()); + // The union of the two historically disjoint producer halves: 7 + 7 with + // `search` shared. + expect(WELL_KNOWN_CAPABILITY_KEYS).toHaveLength(13); }); }); @@ -1036,6 +1126,7 @@ describe('[#4828] scoping (decision 3 — declare what REST actually emits)', () version: '1.0.0', environment: 'development', routes: { data: '/api/v1/data', metadata: '/api/v1/meta' }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'en', supported: ['en'], timezone: 'UTC' }, }; @@ -1103,6 +1194,7 @@ describe('[#4828] resolveDiscoveryEnvironment (decision 4 — enum, not passthro version: '1.0.0', environment: resolveDiscoveryEnvironment(raw), routes: { data: '/api/v1/data', metadata: '/api/v1/meta' }, + capabilities: allCapabilitiesOff, services: minimalServices, locale: { default: 'en', supported: ['en'], timezone: 'UTC' }, }); diff --git a/packages/spec/src/api/discovery.zod.ts b/packages/spec/src/api/discovery.zod.ts index 512c33c286..dc2c824f36 100644 --- a/packages/spec/src/api/discovery.zod.ts +++ b/packages/spec/src/api/discovery.zod.ts @@ -343,6 +343,182 @@ export function resolveDiscoveryEnvironment(raw?: string | null): DiscoveryEnvir return NODE_ENV_TO_DISCOVERY_ENVIRONMENT[raw.trim().toLowerCase()] ?? 'development'; } +// ============================================================================ +// The capability vocabulary (#5672, maintainer ruling A 2026-08-06) +// ============================================================================ + +/** + * Well-Known Capabilities Schema — **the one capability vocabulary**. + * + * Flat boolean flags for quick feature detection by clients (ObjectUI). + * Each flag indicates whether the backend supports a specific capability. + * Clients use these to show/hide UI elements without probing individual + * endpoints. + * + * ## Closed, and why (#5672) + * + * Until the 2026-08-06 ruling this schema was one of TWO de-facto vocabularies. + * `#4828` renamed the runtime dispatcher's top-level `features` map to the + * canonical `capabilities`, which collapsed the *spelling* split — but the two + * producers went on filling **disjoint key sets**: + * + * | producer | keys it filled | + * |:---|:---| + * | `getDiscovery()` (`@objectstack/metadata-protocol`) | `comments` `automation` `cron` `search` `export` `chunkedUpload` `transactionalBatch` | + * | `getDiscoveryInfo()` (`@objectstack/runtime` dispatcher) | `search` `websockets` `files` `analytics` `ai` `notifications` `i18n` | + * + * Only `search` overlapped. `DiscoverySchema.capabilities` was an OPEN + * `z.record`, so both shapes parsed clean and no gate could see the split — + * while `packages/client`'s getter ASSERTED the result was a + * `WellKnownCapabilities`. Against a dispatcher-served host + * `client.capabilities.transactionalBatch` was therefore statically `boolean` + * and actually `undefined`: the type lied. + * + * Ruling A closes the vocabulary here and binds every producer to it: + * + * 1. this schema is the ONE vocabulary — every key explicitly declared, boolean; + * 2. every discovery producer emits EVERY key (see `DiscoverySchema.capabilities`); + * 3. a capability the producer does not deliver is `enabled: false`, **never a + * missing key** — loudly decidable, and a consumer never has to know which + * kind of host answered it; + * 4. so `WellKnownCapabilities` becomes true rather than asserted. + * + * Adding a key here therefore obliges BOTH producers to answer it, and the + * three `discovery-schema-conformance.test.ts` gates fail until they do. + * Removing one is an ADR-0049 enforce-or-remove exercise, not an edit. + */ +export const WellKnownCapabilitiesSchema = lazySchema(() => z.object({ + /** Whether the backend supports record comments / chatter (served by `sys_comment` via the data API) */ + comments: z.boolean().describe('Whether the backend supports record comments / chatter (the `sys_comment` object served via the data API)'), + /** Whether the backend supports Automation CRUD (flows, triggers) */ + automation: z.boolean().describe('Whether the backend supports Automation CRUD (flows, triggers)'), + /** Whether the backend supports cron scheduling */ + cron: z.boolean().describe('Whether the backend supports cron scheduling'), + /** Whether the backend supports full-text search */ + search: z.boolean().describe('Whether the backend supports full-text search'), + /** Whether the backend supports async export */ + export: z.boolean().describe('Whether the backend supports async export'), + /** Whether the backend supports chunked (multipart) uploads */ + chunkedUpload: z.boolean().describe('Whether the backend supports chunked (multipart) uploads'), + /** + * Whether the backend exposes the atomic cross-object batch endpoint + * (`POST {basePath}/batch`, issue #1604 / ADR-0034 item 4): heterogeneous + * create/update/delete across objects that all commit or all roll back in a + * single transaction, with intra-batch `{ $ref: }` parent references. + * + * This lets a client decide **at connection time** whether to send an atomic + * batch or fall back to non-atomic client-side simulation — replacing the + * runtime probe (fire a `/batch` and read 404/405/501). `true` means the route + * is mounted AND the runtime engine can honour a transaction; a backend that + * would 404 (no route) or 501 (no `transaction()`) MUST report `false` + * (declared === enforced). + */ + transactionalBatch: z.boolean().describe( + 'Whether the backend exposes the atomic cross-object batch endpoint (POST {basePath}/batch, #1604/ADR-0034): ' + + 'all ops commit or roll back together in one transaction. Lets clients skip non-atomic client-side simulation ' + + 'instead of runtime-probing 404/405/501. True ⟺ the /batch route is mounted AND the runtime can honour a transaction.' + ), + + // ── Joined the vocabulary with ruling A (#5672) ──────────────────────────── + // These six were the dispatcher's half of the split. They were already REAL + // answers on the wire (`/.well-known/objectstack` has emitted them since + // #4828) — declaring them here does not invent capability, it stops the + // vocabulary from depending on which producer you asked. + + /** + * Whether the backend mounts a realtime push surface (WebSocket or SSE) + * clients can subscribe to. + * + * `false` on every host today, and that is a measured fact rather than a + * placeholder: `service-realtime` is an **in-process pub/sub bus**, the + * dispatcher has no `/realtime` branch and no plugin mounts one (ADR-0076 + * D12, #2462), which is exactly why `ApiRoutesSchema.realtime` is never + * advertised either. A producer that one day mounts a real WS/SSE surface + * flips this — and must also pass the anonymous-access gate (#2567). + */ + websockets: z.boolean().describe( + 'Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. ' + + 'False while realtime is an in-process bus with no mounted HTTP/WS surface (ADR-0076 D12, #2462).' + ), + /** + * Whether a file-storage surface is served at all (upload / download / + * attachment handling), i.e. the `file-storage` slot is filled by something + * that really serves HTTP. + * + * Related to but distinct from {@link WellKnownCapabilitiesSchema} `chunkedUpload`: + * this one says "files work"; that one says "large files can be uploaded in + * chunks". On the hosts that exist today the two coincide, because the only + * storage surface shipped serves both — see the per-producer notes at each + * emit site. + */ + files: z.boolean().describe('Whether a file-storage surface (upload/download/attachments) is served'), + /** Whether the backend serves the analytics / BI query surface */ + analytics: z.boolean().describe('Whether the backend serves the analytics / BI query surface'), + /** Whether the backend serves the AI surface (NLQ, chat, agents, suggest) */ + ai: z.boolean().describe('Whether the backend serves the AI surface (NLQ, chat, agents, suggest)'), + /** Whether the backend serves the notification surface (inbox, delivery) */ + notifications: z.boolean().describe('Whether the backend serves the notification surface (inbox, delivery)'), + /** Whether the backend serves the i18n surface (translations, locale negotiation) */ + i18n: z.boolean().describe('Whether the backend serves the i18n surface (translations, locale negotiation)'), +}).describe('Well-known capability flags for frontend intelligent adaptation')); + +export type WellKnownCapabilities = z.infer; + +/** + * The capability vocabulary as a key list, derived from + * {@link WellKnownCapabilitiesSchema} rather than hand-written. + * + * Every consumer that has to enumerate the vocabulary — the SDK getter that + * flattens a discovery response, the three producer conformance gates — + * reads THIS, so none of them can become a fourth dialect of the contract. + * Hand-listing the keys anywhere is the drift this constant exists to prevent. + */ +export const WELL_KNOWN_CAPABILITY_KEYS = Object.freeze( + Object.keys(WellKnownCapabilitiesSchema.shape) as Array, +); + +/** + * The value shape of one entry in `DiscoverySchema.capabilities`. + * + * `enabled` is the vocabulary's boolean; `features` and `description` are the + * optional hierarchical extras that let a producer say more about a capability + * it does deliver. `features` stays an OPEN record on purpose — sub-feature + * flags are per-capability and per-producer, and #4828 explicitly kept this + * (the surviving `features`) as the declared sub-key. + */ +export const CapabilityDescriptorSchema = lazySchema(() => z.object({ + enabled: z.boolean().describe('Whether this capability is available'), + features: z.record(z.string(), z.boolean()).optional() + .describe('Sub-feature flags within this capability'), + description: z.string().optional() + .describe('Human-readable capability description'), +})); + +export type CapabilityDescriptor = z.infer; + +/** + * `capabilities` as a CLOSED object over the vocabulary — one required entry + * per {@link WellKnownCapabilitiesSchema} key, built from that schema's own + * shape so the two cannot drift apart (ruling A point 1: there is one + * vocabulary, not two that a test has to keep in step). + * + * Each key's `.describe()` is inherited from the flag it mirrors, so the + * generated reference docs say the same thing in both places. + * + * A function, not a module-level constant: reading `.shape` materialises the + * lazy schema, so evaluating this at import time would undo exactly the + * deferral {@link lazySchema} exists for. It is called from inside + * `DiscoverySchema`'s own factory, i.e. on first use of that schema. + */ +function capabilityMapShape(): Record { + return Object.fromEntries( + Object.entries(WellKnownCapabilitiesSchema.shape).map(([key, flag]) => { + const description = (flag as z.ZodTypeAny).description; + return [key, description ? CapabilityDescriptorSchema.describe(description) : CapabilityDescriptorSchema]; + }), + ) as Record; +} + export const DiscoverySchema = lazySchema(() => z.object({ /** System Identity */ name: z.string(), @@ -370,18 +546,30 @@ export const DiscoverySchema = lazySchema(() => z.object({ ), /** - * Hierarchical capability descriptors. - * Declares platform features so clients can adapt UI without probing individual services. - * Each key is a capability domain (e.g., "comments", "automation", "search"), - * and its value describes what sub-features are available. + * Hierarchical capability descriptors — **the whole vocabulary, every time**. + * + * One entry per {@link WellKnownCapabilitiesSchema} key, every entry + * REQUIRED. Ruling A (#5672): a capability a producer does not deliver is + * reported `enabled: false`, never omitted, so a consumer reads the same key + * set from every host and never has to know which producer answered. + * + * Two things changed here at once, and both were load-bearing: + * + * * **open `z.record` → closed object.** The record accepted any key, which + * is how two producers filled disjoint key sets for a year without a gate + * noticing. Closed, an undeclared capability is a contract change you have + * to make in {@link WellKnownCapabilitiesSchema} — where both producers are + * then obliged to answer it. (A zod object STRIPS unknown keys rather than + * rejecting them, so the producer gates also carry a key-set check, exactly + * as `routes` does since #5679.) + * * **optional → required.** This is the `scoping` precedent read the other + * way round. `scoping` is optional because only ONE producer can honestly + * answer it; `capabilities` is answerable by all of them, and an optional + * block would leave the consumer back at `undefined` for every flag — + * precisely the pre-#4828 dispatcher situation the ruling removes. */ - capabilities: z.record(z.string(), z.object({ - enabled: z.boolean().describe('Whether this capability is available'), - features: z.record(z.string(), z.boolean()).optional() - .describe('Sub-feature flags within this capability'), - description: z.string().optional() - .describe('Human-readable capability description'), - })).optional().describe('Hierarchical capability descriptors for frontend intelligent adaptation'), + capabilities: z.object(capabilityMapShape()) + .describe('Hierarchical capability descriptors — the full WellKnownCapabilities vocabulary, every key present'), /** * Schema discovery URLs for cross-ecosystem interoperability. @@ -424,46 +612,6 @@ export const DiscoverySchema = lazySchema(() => z.object({ metadata: z.record(z.string(), z.unknown()).optional().describe('Custom metadata key-value pairs for extensibility'), })); -/** - * Well-Known Capabilities Schema - * Flat boolean flags for quick feature detection by clients (ObjectUI). - * Each flag indicates whether the backend supports a specific capability. - * Clients can use these to show/hide UI elements without probing individual endpoints. - */ -export const WellKnownCapabilitiesSchema = lazySchema(() => z.object({ - /** Whether the backend supports record comments / chatter (served by `sys_comment` via the data API) */ - comments: z.boolean().describe('Whether the backend supports record comments / chatter (the `sys_comment` object served via the data API)'), - /** Whether the backend supports Automation CRUD (flows, triggers) */ - automation: z.boolean().describe('Whether the backend supports Automation CRUD (flows, triggers)'), - /** Whether the backend supports cron scheduling */ - cron: z.boolean().describe('Whether the backend supports cron scheduling'), - /** Whether the backend supports full-text search */ - search: z.boolean().describe('Whether the backend supports full-text search'), - /** Whether the backend supports async export */ - export: z.boolean().describe('Whether the backend supports async export'), - /** Whether the backend supports chunked (multipart) uploads */ - chunkedUpload: z.boolean().describe('Whether the backend supports chunked (multipart) uploads'), - /** - * Whether the backend exposes the atomic cross-object batch endpoint - * (`POST {basePath}/batch`, issue #1604 / ADR-0034 item 4): heterogeneous - * create/update/delete across objects that all commit or all roll back in a - * single transaction, with intra-batch `{ $ref: }` parent references. - * - * This lets a client decide **at connection time** whether to send an atomic - * batch or fall back to non-atomic client-side simulation — replacing the - * runtime probe (fire a `/batch` and read 404/405/501). `true` means the route - * is mounted AND the runtime engine can honour a transaction; a backend that - * would 404 (no route) or 501 (no `transaction()`) MUST report `false` - * (declared === enforced). - */ - transactionalBatch: z.boolean().describe( - 'Whether the backend exposes the atomic cross-object batch endpoint (POST {basePath}/batch, #1604/ADR-0034): ' - + 'all ops commit or roll back together in one transaction. Lets clients skip non-atomic client-side simulation ' - + 'instead of runtime-probing 404/405/501. True ⟺ the /batch route is mounted AND the runtime can honour a transaction.' - ), -}).describe('Well-known capability flags for frontend intelligent adaptation')); - -export type WellKnownCapabilities = z.infer; export type DiscoveryResponse = z.infer; export type ApiRoutes = z.infer; export type ServiceInfo = z.infer; diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 5533727535..0dd9bc12f9 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -390,14 +390,19 @@ describe('ObjectStack Protocol', () => { // GetDiscoveryResponseSchema — capabilities // ========================================== import { GetDiscoveryResponseSchema } from './protocol.zod'; +import { WELL_KNOWN_CAPABILITY_KEYS } from './discovery.zod'; describe('GetDiscoveryResponseSchema (capabilities)', () => { + /** The full vocabulary as hierarchical descriptors, all off. */ + const allCapabilitiesOff = () => + Object.fromEntries(WELL_KNOWN_CAPABILITY_KEYS.map(k => [k, { enabled: false }])); + it('should accept response with hierarchical capabilities', () => { const result = GetDiscoveryResponseSchema.safeParse({ version: 'v1', name: 'ObjectStack API', capabilities: { - feed: { enabled: true }, + ...allCapabilitiesOff(), comments: { enabled: true, features: { threaded: true } }, automation: { enabled: false }, search: { enabled: true, description: 'Full-text search' }, @@ -405,16 +410,57 @@ describe('GetDiscoveryResponseSchema (capabilities)', () => { }); expect(result.success).toBe(true); if (result.success) { - expect(result.data.capabilities?.feed?.enabled).toBe(true); + expect(result.data.capabilities?.comments?.enabled).toBe(true); + expect(result.data.capabilities?.comments?.features?.threaded).toBe(true); expect(result.data.capabilities?.automation?.enabled).toBe(false); } }); + // [#5672] This fixture used to lead with `feed: { enabled: true }` and assert + // it round-tripped. It did — `capabilities` was an open `z.record`, so a key + // no producer emits and no consumer reads looked exactly like a real one. + // That is the phantom the closed vocabulary removes, and it is worth a test + // of its own rather than a silent deletion. + it('[#5672] rejects a capability key outside the vocabulary', () => { + const result = GetDiscoveryResponseSchema.safeParse({ + version: 'v1', + name: 'ObjectStack API', + capabilities: { ...allCapabilitiesOff(), feed: { enabled: true } }, + }); + + // A zod object STRIPS unknown keys rather than failing, so the parse itself + // stays green — the fact to pin is that `feed` does not survive into the + // parsed value, i.e. a consumer reading the spec-parsed body can never see + // it. (The producers' own gates carry the complementary key-set check that + // makes emitting it an error rather than a silent drop.) + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.capabilities).not.toHaveProperty('feed'); + } + }); + + it('[#5672] rejects a capability map that is missing part of the vocabulary', () => { + const { comments: _dropped, ...partial } = allCapabilitiesOff(); + const result = GetDiscoveryResponseSchema.safeParse({ + version: 'v1', + name: 'ObjectStack API', + capabilities: partial, + }); + + // `.partial()` makes the `capabilities` BLOCK optional at this layer; it + // does not make the vocabulary inside it optional. Present ⇒ complete. + expect(result.success).toBe(false); + }); + it('should accept response without capabilities (optional)', () => { const result = GetDiscoveryResponseSchema.safeParse({ version: 'v1', apiName: 'ObjectStack API', }); + // Still optional HERE and only here: `GetDiscoveryResponseSchema` is + // `DiscoverySchema.partial()`, the lenient wire wrapper. The canonical + // `DiscoverySchema` — the one every producer's conformance gate parses + // against since #4828 — requires it (#5672). expect(result.success).toBe(true); if (result.success) { expect(result.data.capabilities).toBeUndefined();