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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .changeset/unified-capability-vocabulary.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 22 additions & 3 deletions content/docs/references/api/discovery.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ not been verified (may 501 at runtime).
## TypeScript Usage

```typescript
import { ApiRoutesSchema, DiscoverySchema, 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);
Expand Down Expand Up @@ -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<string, boolean>` | optional | Sub-feature flags within this capability |
| **description** | `string` | optional | Human-readable capability description |


---

## Discovery
Expand All @@ -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<string, { enabled: boolean; status: Enum<'available' \| 'registered' \| 'unavailable' \| 'degraded' \| 'stub'>; handlerReady?: boolean; route?: string; … }>` | ✅ | Per-service availability map keyed by CoreServiceName |
| **capabilities** | `Record<string, { enabled: boolean; features?: Record<string, boolean>; description?: string }>` | optional | Hierarchical capability descriptors for frontend intelligent adaptation |
| **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<string, any>` | optional | Custom metadata key-value pairs for extensibility |
Expand Down Expand Up @@ -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) |


---
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { enabled: boolean; status: Enum<'available' \| 'registered' \| 'unavailable' \| 'degraded' \| 'stub'>; handlerReady?: boolean; route?: string; … }>` | optional | Per-service availability map keyed by CoreServiceName |
| **capabilities** | `Record<string, { enabled: boolean; features?: Record<string, boolean>; description?: string }>` | optional | Hierarchical capability descriptors for frontend intelligent adaptation |
| **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<string, any>` | optional | Custom metadata key-value pairs for extensibility |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ directory rather than per file.
| Dir | Sites |
|---|---|
| `ai/` | 77 |
| `api/` | 394 |
| `api/` | 395 |
| `cloud/` | 82 |
| `identity/` | 33 |
| `integration/` | 10 |
Expand Down
133 changes: 133 additions & 0 deletions packages/client/src/capabilities-vocabulary.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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();
});
});
Loading
Loading