diff --git a/.changeset/client-actions-surface.md b/.changeset/client-actions-surface.md
new file mode 100644
index 0000000000..45bcdb7ef4
--- /dev/null
+++ b/.changeset/client-actions-surface.md
@@ -0,0 +1,23 @@
+---
+"@objectstack/client": minor
+"@objectstack/runtime": patch
+---
+
+feat(client): `actions` surface — the SDK path to server-registered actions (#3563 PR-2)
+
+`client.actions.invoke(object, action, { recordId, params })` and
+`client.actions.invokeGlobal(action, opts)` dispatch handlers registered via
+`engine.registerAction` (`POST /api/v1/actions/...`). This closes the largest
+gap in the #3563 route audit: the whole `/actions` domain — the documented way
+to expose custom server-side operations — was unreachable from the SDK, and
+every console hand-rolled `fetch` for it. The record id travels in the body,
+which both server URL shapes honor; the handler's own business failure comes
+back as `{ success: false, error }` rather than a thrown exception.
+
+The route ledger flips all three `/actions` rows to `sdk` and the gap ratchet
+drops 27 → 24. Also takes the documentation-drift findings from the audit:
+the client README no longer documents six methods that do not exist,
+`CLIENT_SPEC_COMPLIANCE.md` is retired to a tombstone pointing at the
+CI-enforced ledger (its "FULLY COMPLIANT" verdict was measured against a
+route table nothing consumes), and the docs-site SDK page documents the new
+surface.
diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx
index fdfec3e004..596342ab7a 100644
--- a/content/docs/api/client-sdk.mdx
+++ b/content/docs/api/client-sdk.mdx
@@ -117,7 +117,8 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
| **views** | ✅ | 5 | UI view definitions |
| **workflow** | ✅ | 3 | Workflow state transitions |
| **analytics** | ✅ | 3 | Analytics queries |
-| **automation** | ✅ | 1 | Automation triggers |
+| **automation** | ✅ | 15 | Flow CRUD, trigger/execute, runs, screen-flow resume |
+| **actions** | ✅ | 2 | Server-registered action handlers (`engine.registerAction`) |
| **storage** | ✅ | 2 | File upload & download |
| **i18n** | ✅ | 3 | Internationalization |
| **notifications** | 🟡 | 7 | Legacy notification helpers; receipt/inbox cut-over pending |
@@ -125,7 +126,13 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
| **ai** | ✅ | 3 | AI services (NLQ, suggest, insights) |
-**Protocol compliance & verification**: See [`CLIENT_SPEC_COMPLIANCE.md`](https://github.com/objectstack-ai/objectstack/blob/main/packages/client/CLIENT_SPEC_COMPLIANCE.md) for detailed method-by-method verification and [`CLIENT_SERVER_INTEGRATION_TESTS.md`](https://github.com/objectstack-ai/objectstack/blob/main/packages/client/CLIENT_SERVER_INTEGRATION_TESTS.md) for comprehensive integration test specifications.
+**Coverage is CI-enforced, not hand-asserted**: the #3563 route ledger
+([`route-ledger.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/runtime/src/route-ledger.ts))
+records the audited disposition of every server route, and conformance tests
+on both sides fail when a route lands without a reviewed disposition or the
+ledger names a client method that doesn't exist. Integration test
+specifications live in
+[`CLIENT_SERVER_INTEGRATION_TESTS.md`](https://github.com/objectstack-ai/objectstack/blob/main/packages/client/CLIENT_SERVER_INTEGRATION_TESTS.md).
---
@@ -332,6 +339,19 @@ if (run.status === 'paused') {
// (a page reload, another tab, an inbox):
await client.automation.getScreen('convert_lead', runId);
+// Actions — Invoke server-registered action handlers
+// (engine.registerAction). The result envelope is the HANDLER's own
+// `{ success, data | error }` — a business failure comes back as
+// `success: false` + `error` instead of a thrown exception, so it can be
+// surfaced as a toast.
+const res = await client.actions.invoke('crm_lead', 'convert', {
+ recordId,
+ params: { create_opportunity: true },
+});
+if (!res.success) console.warn(res.error);
+// Global (object-less) actions dispatch to the wildcard handler:
+await client.actions.invokeGlobal('nightly_cleanup', { params: { dryRun: true } });
+
// Storage — File upload and management
await client.storage.upload(fileData, 'user');
await client.storage.getDownloadUrl('file-123');
diff --git a/packages/client/CLIENT_SPEC_COMPLIANCE.md b/packages/client/CLIENT_SPEC_COMPLIANCE.md
index 5f5da81231..3770749db7 100644
--- a/packages/client/CLIENT_SPEC_COMPLIANCE.md
+++ b/packages/client/CLIENT_SPEC_COMPLIANCE.md
@@ -1,361 +1,26 @@
-# @objectstack/client - Spec API Protocol Compliance Matrix
-
-## Overview
-
-This document verifies that `@objectstack/client` correctly implements all methods required by the `@objectstack/spec` API protocol specification.
-
-**Status**: ✅ **FULLY COMPLIANT** (as of 2026-02-09)
-
----
-
-## API Namespaces
-
-The spec defines 13 API namespaces via `DEFAULT_DISPATCHER_ROUTES` in `/packages/spec/src/api/dispatcher.zod.ts`:
-
-| Namespace | Service | Auth Required | Criticality | Client Implementation |
-|-----------|---------|:-------------:|:-----------:|:--------------------:|
-| `/api/v1/discovery` | metadata | ❌ | required | ✅ `connect()` |
-| `/api/v1/meta` | metadata | ✅ | required | ✅ `meta.*` |
-| `/api/v1/data` | data | ✅ | required | ✅ `data.*` |
-| `/api/v1/auth` | auth | ✅ | required | ✅ `auth.*` |
-| `/api/v1/packages` | metadata | ✅ | optional | ✅ `packages.*` |
-| `/api/v1/ui` | ui | ✅ | optional | ✅ `views.*` |
-| `/api/v1/workflow` | workflow | ✅ | optional | ✅ `workflow.*` |
-| `/api/v1/analytics` | analytics | ✅ | optional | ✅ `analytics.*` |
-| `/api/v1/automation` | automation | ✅ | optional | ✅ `automation.*` |
-| `/api/v1/i18n` | i18n | ✅ | optional | ✅ `i18n.*` |
-| `/api/v1/notifications` | notification | ✅ | optional | ✅ `notifications.*` |
-| `/api/v1/realtime` | realtime | ✅ | optional | ✅ `realtime.*` |
-| `/api/v1/ai` | ai | ✅ | optional | ✅ `ai.*` |
-
----
-
-## Method-by-Method Compliance
-
-### 1. Discovery & Metadata (`/api/v1/meta`, `/api/v1/discovery`)
-
-Protocol methods defined in `packages/spec/src/api/protocol.zod.ts`:
-
-| Spec Method | Request Schema | Response Schema | Client Method | Status |
-|-------------|----------------|-----------------|---------------|:------:|
-| Get Discovery | `GetDiscoveryRequestSchema` | `GetDiscoveryResponseSchema` | `connect()` | ✅ |
-| Get Meta Types | `GetMetaTypesRequestSchema` | `GetMetaTypesResponseSchema` | `meta.getTypes()` | ✅ |
-| Get Meta Items | `GetMetaItemsRequestSchema` | `GetMetaItemsResponseSchema` | `meta.getItems()` | ✅ |
-| Get Meta Item | `GetMetaItemRequestSchema` | `GetMetaItemResponseSchema` | `meta.getItem()` | ✅ |
-| Save Meta Item | `SaveMetaItemRequestSchema` | `SaveMetaItemResponseSchema` | `meta.saveItem()` | ✅ |
-| Get Object (cached) | `MetadataCacheRequestSchema` | `MetadataCacheResponseSchema` | `meta.getCached()` | ✅ |
-| Get Object (deprecated) | - | - | `meta.getObject()` | ✅ |
-
-**Notes:**
-- `meta.getObject()` is marked deprecated in favor of `meta.getItem('object', name)`
-- Cache support via ETag/If-None-Match headers is implemented in `getCached()`
-
----
-
-### 2. Data Operations (`/api/v1/data`)
-
-#### CRUD Operations
-
-| Spec Method | Request Schema | Response Schema | Client Method | Status |
-|-------------|----------------|-----------------|---------------|:------:|
-| Find Data | `FindDataRequestSchema` | `FindDataResponseSchema` | `data.find()` | ✅ |
-| Query Data (Advanced) | `QueryDataRequestSchema` | `QueryDataResponseSchema` | `data.query()` | ✅ |
-| Get Data | `GetDataRequestSchema` | `GetDataResponseSchema` | `data.get()` | ✅ |
-| Create Data | `CreateDataRequestSchema` | `CreateDataResponseSchema` | `data.create()` | ✅ |
-| Update Data | `UpdateDataRequestSchema` | `UpdateDataResponseSchema` | `data.update()` | ✅ |
-| Delete Data | `DeleteDataRequestSchema` | `DeleteDataResponseSchema` | `data.delete()` | ✅ |
-
-#### Batch Operations
-
-| Spec Method | Request Schema | Response Schema | Client Method | Status |
-|-------------|----------------|-----------------|---------------|:------:|
-| Batch Operations | `BatchUpdateRequestSchema` | `BatchUpdateResponseSchema` | `data.batch()` | ✅ |
-| Create Many | `CreateManyRequestSchema` | `CreateManyResponseSchema` | `data.createMany()` | ✅ |
-| Update Many | `UpdateManyRequestSchema` | `UpdateManyResponseSchema` | `data.updateMany()` | ✅ |
-| Delete Many | `DeleteManyRequestSchema` | `DeleteManyResponseSchema` | `data.deleteMany()` | ✅ |
-
-**Notes:**
-- `data.find()` supports simplified query parameters (filters, sort, pagination)
-- `data.query()` supports full ObjectQL AST for complex queries
-- Batch operations support `BatchOptions` for transaction control
-
----
-
-### 3. Authentication (`/api/v1/auth`)
-
-| Spec Method | Request Schema | Response Schema | Client Method | Status |
-|-------------|----------------|-----------------|---------------|:------:|
-| Login | `LoginRequestSchema` | `SessionResponseSchema` | `auth.login()` | ✅ |
-| Register | `RegisterRequestSchema` | `SessionResponseSchema` | `auth.register()` | ✅ |
-| Logout | `LogoutRequestSchema` | `LogoutResponseSchema` | `auth.logout()` | ✅ |
-| Refresh Token | `RefreshTokenRequestSchema` | `SessionResponseSchema` | `auth.refreshToken()` | ✅ |
-| Get Current User | `GetCurrentUserRequestSchema` | `GetCurrentUserResponseSchema` | `auth.me()` | ✅ |
-
----
-
-### 4. Package Management (`/api/v1/packages`)
-
-| Spec Method | Request Schema | Response Schema | Client Method | Status |
-|-------------|----------------|-----------------|---------------|:------:|
-| List Packages | `ListPackagesRequestSchema` | `ListPackagesResponseSchema` | `packages.list()` | ✅ |
-| Get Package | `GetPackageRequestSchema` | `GetPackageResponseSchema` | `packages.get()` | ✅ |
-| Install Package | `InstallPackageRequestSchema` | `InstallPackageResponseSchema` | `packages.install()` | ✅ |
-| Uninstall Package | `UninstallPackageRequestSchema` | `UninstallPackageResponseSchema` | `packages.uninstall()` | ✅ |
-| Enable Package | `EnablePackageRequestSchema` | `EnablePackageResponseSchema` | `packages.enable()` | ✅ |
-| Disable Package | `DisablePackageRequestSchema` | `DisablePackageResponseSchema` | `packages.disable()` | ✅ |
-
----
-
-### 5. View Management (`/api/v1/ui`)
-
-| Spec Method | Request Schema | Response Schema | Client Method | Status |
-|-------------|----------------|-----------------|---------------|:------:|
-| List Views | `ListViewsRequestSchema` | `ListViewsResponseSchema` | `views.list()` | ✅ |
-| Get View | `GetViewRequestSchema` | `GetViewResponseSchema` | `views.get()` | ✅ |
-| Create View | `CreateViewRequestSchema` | `CreateViewResponseSchema` | `views.create()` | ✅ |
-| Update View | `UpdateViewRequestSchema` | `UpdateViewResponseSchema` | `views.update()` | ✅ |
-| Delete View | `DeleteViewRequestSchema` | `DeleteViewResponseSchema` | `views.delete()` | ✅ |
-
----
-
-### 6. Permissions (`/api/v1/auth/permissions`)
-
-| Spec Method | Request Schema | Response Schema | Client Method | Status |
-|-------------|----------------|-----------------|---------------|:------:|
-| Check Permission | `CheckPermissionRequestSchema` | `CheckPermissionResponseSchema` | `permissions.check()` | ✅ |
-| Get Object Permissions | `GetObjectPermissionsRequestSchema` | `GetObjectPermissionsResponseSchema` | `permissions.getObjectPermissions()` | ✅ |
-| Get Effective Permissions | `GetEffectivePermissionsRequestSchema` | `GetEffectivePermissionsResponseSchema` | `permissions.getEffectivePermissions()` | ✅ |
-
-**Notes:**
-- Permission endpoints are served under `/api/v1/auth` per spec's `plugin-rest-api.zod.ts`
-- Supports action types: `create`, `read`, `edit`, `delete`, `transfer`, `restore`, `purge`
-- `check()` uses POST method as per spec
-- `getObjectPermissions()` and `getEffectivePermissions()` use GET methods
-
----
-
-### 7. Workflow (`/api/v1/workflow`)
-
-| Spec Method | Request Schema | Response Schema | Client Method | Status |
-|-------------|----------------|-----------------|---------------|:------:|
-| Get Workflow Config | `GetWorkflowConfigRequestSchema` | `GetWorkflowConfigResponseSchema` | `workflow.getConfig()` | ✅ |
-| Get Workflow State | `GetWorkflowStateRequestSchema` | `GetWorkflowStateResponseSchema` | `workflow.getState()` | ✅ |
-| Workflow Transition | `WorkflowTransitionRequestSchema` | `WorkflowTransitionResponseSchema` | `workflow.transition()` | ✅ |
-| Workflow Approve | `WorkflowApproveRequestSchema` | `WorkflowApproveResponseSchema` | `workflow.approve()` | ✅ |
-| Workflow Reject | `WorkflowRejectRequestSchema` | `WorkflowRejectResponseSchema` | `workflow.reject()` | ✅ |
-
----
-
-### 8. Realtime (`/api/v1/realtime`)
-
-| Spec Method | Request Schema | Response Schema | Client Method | Status |
-|-------------|----------------|-----------------|---------------|:------:|
-| Connect | `RealtimeConnectRequestSchema` | `RealtimeConnectResponseSchema` | `realtime.connect()` | ✅ |
-| Disconnect | `RealtimeDisconnectRequestSchema` | `RealtimeDisconnectResponseSchema` | `realtime.disconnect()` | ✅ |
-| Subscribe | `RealtimeSubscribeRequestSchema` | `RealtimeSubscribeResponseSchema` | `realtime.subscribe()` | ✅ |
-| Unsubscribe | `RealtimeUnsubscribeRequestSchema` | `RealtimeUnsubscribeResponseSchema` | `realtime.unsubscribe()` | ✅ |
-| Set Presence | `SetPresenceRequestSchema` | `SetPresenceResponseSchema` | `realtime.setPresence()` | ✅ |
-| Get Presence | `GetPresenceRequestSchema` | `GetPresenceResponseSchema` | `realtime.getPresence()` | ✅ |
-
----
-
-### 9. Notifications (`/api/v1/notifications`)
-
-| Spec Method | Request Schema | Response Schema | Client Method | Status |
-|-------------|----------------|-----------------|---------------|:------:|
-| Register Device | `RegisterDeviceRequestSchema` | `RegisterDeviceResponseSchema` | `notifications.registerDevice()` | ✅ |
-| Unregister Device | `UnregisterDeviceRequestSchema` | `UnregisterDeviceResponseSchema` | `notifications.unregisterDevice()` | ✅ |
-| Get Preferences | `GetNotificationPreferencesRequestSchema` | `GetNotificationPreferencesResponseSchema` | `notifications.getPreferences()` | ✅ |
-| Update Preferences | `UpdateNotificationPreferencesRequestSchema` | `UpdateNotificationPreferencesResponseSchema` | `notifications.updatePreferences()` | ✅ |
-| List Notifications | `ListNotificationsRequestSchema` | `ListNotificationsResponseSchema` | `notifications.list()` | ✅ |
-| Mark Read | `MarkNotificationsReadRequestSchema` | `MarkNotificationsReadResponseSchema` | `notifications.markRead()` | ✅ |
-| Mark All Read | `MarkAllNotificationsReadRequestSchema` | `MarkAllNotificationsReadResponseSchema` | `notifications.markAllRead()` | ✅ |
-
----
-
-### 10. AI Services (`/api/v1/ai`)
-
-| Spec Method | Request Schema | Response Schema | Client Method | Status |
-|-------------|----------------|-----------------|---------------|:------:|
-| Natural Language Query | `AiNlqRequestSchema` | `AiNlqResponseSchema` | `ai.nlq()` | ✅ |
-| AI Chat | `AiChatRequestSchema` | `AiChatResponseSchema` | `ai.chat()` | ✅ |
-| AI Suggestions | `AiSuggestRequestSchema` | `AiSuggestResponseSchema` | `ai.suggest()` | ✅ |
-| AI Insights | `AiInsightsRequestSchema` | `AiInsightsResponseSchema` | `ai.insights()` | ✅ |
-
----
-
-### 11. Automation (`/api/v1/automation`)
-
-| Spec Method | Request Schema | Response Schema | Client Method | Status |
-|-------------|----------------|-----------------|---------------|:------:|
-| Trigger Automation | `AutomationTriggerRequestSchema` | `AutomationTriggerResponseSchema` | `automation.trigger()` | ✅ |
-
-**Notes:**
-- Schema defined in `packages/client/src/index.ts` (lines 50-59)
-- Allows triggering named automations with arbitrary payloads
-- Method signature: `trigger(triggerName: string, payload: any)`
-
----
-
-### 12. Internationalization (`/api/v1/i18n`)
-
-| Spec Method | Request Schema | Response Schema | Client Method | Status |
-|-------------|----------------|-----------------|---------------|:------:|
-| Get Locales | `GetLocalesRequestSchema` | `GetLocalesResponseSchema` | `i18n.getLocales()` | ✅ |
-| Get Translations | `GetTranslationsRequestSchema` | `GetTranslationsResponseSchema` | `i18n.getTranslations()` | ✅ |
-| Get Field Labels | `GetFieldLabelsRequestSchema` | `GetFieldLabelsResponseSchema` | `i18n.getFieldLabels()` | ✅ |
-
----
-
-### 13. Analytics (`/api/v1/analytics`)
-
-| Spec Method | Request Schema | Response Schema | Client Method | Status |
-|-------------|----------------|-----------------|---------------|:------:|
-| Analytics Query | `AnalyticsQueryRequestSchema` | `AnalyticsResultResponseSchema` | `analytics.query()` | ✅ |
-| Get Analytics Meta | `GetAnalyticsMetaRequestSchema` | `AnalyticsMetadataResponseSchema` | `analytics.meta(cube)` | ✅ |
-
----
-
-## Storage Operations
-
-The client implements file storage operations though they're not explicitly defined as a separate namespace in DEFAULT_DISPATCHER_ROUTES:
-
-| Operation | Client Method | Status |
-|-----------|---------------|:------:|
-| Get Presigned URL | `storage.getPresignedUrl()` | ✅ |
-| Upload File | `storage.upload()` | ✅ |
-| Complete Upload | `storage.completeUpload()` | ✅ |
-| Get Download URL | `storage.getDownloadUrl()` | ✅ |
-
-**Note:** Storage operations use the `/api/v1/storage` prefix though not in DEFAULT_DISPATCHER_ROUTES. This is expected as storage can be plugin-provided.
-
----
-
-## Hub Operations
-
-The client implements hub connectivity operations:
-
-| Operation | Client Method | Status |
-|-----------|---------------|:------:|
-| Connect to Hub | `hub.connect()` | ✅ |
-
----
-
-## Architecture & Implementation Notes
-
-### Request/Response Envelope
-
-The client correctly implements the standard response envelope pattern:
-- All server responses wrapped in `{ success: boolean, data: T, meta?: any }`
-- `unwrapResponse()` helper extracts inner `data` payload
-- Error responses use `ApiErrorSchema` with `StandardErrorCode` enum
-
-### Authentication
-
-- JWT token passed via `Authorization: Bearer ` header
-- Token configurable via `ClientConfig.token`
-- `auth.login()` returns session with token for subsequent requests
-
-### HTTP Methods
-
-Client uses correct HTTP verbs per REST conventions:
-- `GET` for read operations (find, get, list)
-- `POST` for create and complex queries (create, query, batch operations)
-- `PATCH` for updates (update)
-- `PUT` for save/upsert (saveItem)
-- `DELETE` for deletions (delete)
-
-### Query Strategies
-
-The client supports three query approaches:
-1. **Simplified** (`data.find()`) - Query params for basic filters
-2. **AST** (`data.query()`) - Full ObjectQL AST via POST body
-3. **Direct** (`data.get()`) - Retrieve by ID
-
-### Route Resolution
-
-- `getRoute(namespace)` helper resolves API prefix from discovery info
-- Fallback to `/api/v1/{namespace}` if discovery not available
-- Supports custom base URLs via `ClientConfig.baseUrl`
-
----
-
-## Compliance Summary
-
-✅ **All 13 API namespaces implemented**
-✅ **All required core services (discovery, meta, data, auth) implemented**
-✅ **All optional services implemented**
-✅ **95+ protocol methods implemented**
-✅ **Correct request/response schema usage**
-✅ **Proper HTTP verbs and URL patterns**
-✅ **Authentication support**
-✅ **Batch operations support**
-✅ **Cache support (ETag, If-None-Match)**
-
----
-
-## Testing Requirements
-
-To verify client-server integration, tests should cover:
-
-1. **Connection & Discovery**
- - ✓ Standard discovery via `.well-known/objectstack`
- - ✓ Fallback discovery via `/api/v1`
- - ✓ Capability detection
- - ✓ Route mapping
-
-2. **Authentication Flow**
- - ✓ Login (email/password, magic link, social)
- - ✓ Token management
- - ✓ Session refresh
- - ✓ Logout
-
-3. **CRUD Operations**
- - ✓ Create single/many records
- - ✓ Read with filters, pagination, sorting
- - ✓ Update single/many records
- - ✓ Delete single/many records
- - ✓ Batch mixed operations
-
-4. **Advanced Features**
- - ✓ Complex queries (ObjectQL AST)
- - ✓ Metadata caching (ETag)
- - ✓ Workflow transitions
- - ✓ Permission checks
- - ✓ Realtime subscriptions
- - ✓ File uploads/downloads
- - ✓ AI operations
-
-5. **Error Handling**
- - ✓ Network errors
- - ✓ 4xx client errors
- - ✓ 5xx server errors
- - ✓ Standard error codes
- - ✓ Validation errors
-
-See `CLIENT_SERVER_INTEGRATION_TESTS.md` for detailed test specifications.
-
----
-
-## Version Compatibility
-
-| Package | Version | Compatibility |
-|---------|---------|---------------|
-| `@objectstack/spec` | Latest | ✅ Fully compatible |
-| `@objectstack/client` | Latest | ✅ Implements all protocols |
-| `@objectstack/core` | Latest | ✅ Required dependency |
-
----
-
-## Related Documentation
-
-- [Spec Protocol Map](../spec/PROTOCOL_MAP.md)
-- [REST API Plugin](../spec/REST_API_PLUGIN.md)
-- [Client README](./README.md)
-- [Integration Test Suite](./CLIENT_SERVER_INTEGRATION_TESTS.md)
-
----
-
-**Last Updated:** 2026-02-09
-**Reviewed By:** GitHub Copilot Agent
-**Status:** ✅ Verified Complete
+# @objectstack/client — Spec Compliance Matrix (RETIRED)
+
+> **Retired 2026-07-27 by the #3563 route audit.** This document's
+> "✅ FULLY COMPLIANT" verdict (last verified 2026-02-09) was measured against
+> `DEFAULT_DISPATCHER_ROUTES` in `@objectstack/spec` — a table nothing in the
+> runtime consumes, which lists two domains that do not exist (`/workflow`
+> as a dispatcher route, `/realtime`) and omits eight that do (`/keys`,
+> `/mcp`, `/mcp/skill`, `/actions`, `/security`, `/share-links`, `/ready`,
+> `/openapi.json`). Measured against the server's real route surface, the
+> audit found 27 routes with no SDK expression on the day this file still
+> claimed full compliance.
+>
+> Hand-maintained compliance tables drift; this one is not coming back.
+
+Coverage is now asserted by code, in CI, from both sides:
+
+- **Inventory:** `packages/runtime/src/route-ledger.ts` — the audited
+ disposition of every dispatcher route.
+- **Guard:** `packages/runtime/src/route-ledger.conformance.test.ts`
+ (dispatcher domains ↔ ledger, gap ratchet) and
+ `packages/client/src/route-ledger-coverage.test.ts` (every ledger-named
+ client method must exist).
+- **Findings & follow-ups:**
+ `docs/audits/2026-07-dispatcher-client-route-coverage.md`.
+
+This file is kept only so inbound links don't break.
diff --git a/packages/client/README.md b/packages/client/README.md
index f8c9fde7fb..c717cf7b78 100644
--- a/packages/client/README.md
+++ b/packages/client/README.md
@@ -11,8 +11,8 @@ The official TypeScript client for ObjectStack.
- **Batch Operations**: Efficient bulk create/update/delete with transaction support.
- **View Storage**: Save, load, and share custom UI view configurations.
- **Standardized Errors**: Machine-readable error codes with retry guidance.
-- **Full Protocol Coverage**: Implements all 13 API namespaces defined in `@objectstack/spec`
-- **95+ Methods**: Complete implementation of discovery, metadata, data, auth, workflow, realtime, AI, and more.
+- **Broad Protocol Coverage**: 20 top-level surfaces (~170 methods) over the ObjectStack HTTP API.
+- **Audited Surface**: coverage against the server's routes is tracked by the #3563 route ledger (`@objectstack/runtime` `route-ledger.ts`), enforced in CI.
## 🤖 AI Development Context
@@ -47,7 +47,7 @@ async function main() {
await client.connect();
// 3. Metadata Access
- const todoSchema = await client.meta.getObject('todo_task');
+ const todoSchema = await client.meta.getItem('object', 'todo_task');
console.log('Fields:', todoSchema.fields);
// Save Metadata (New Feature)
@@ -93,10 +93,9 @@ async function main() {
}
// 8. View Storage (New!)
- const view = await client.views.create({
+ const view = await client.views.create('todo_task', {
name: 'active_tasks',
label: 'Active Tasks',
- object: 'todo_task',
type: 'list',
visibility: 'public',
query: {
@@ -121,9 +120,10 @@ async function main() {
Initializes the client by fetching the system discovery manifest from `/api/v1`.
### `client.meta`
-- `getObject(name: string)`: Get object schema.
-- `getCached(name: string, options?)`: Get object schema with ETag-based caching.
-- `getView(name: string)`: Get view configuration.
+- `getTypes()` / `getItems(type)` / `getItem(type, name)`: Read metadata (e.g. `getItem('object', 'todo_task')`).
+- `saveItem(type, name, data)` / `deleteItem(type, name)`: Write metadata.
+- `getCached(name, options?)`: Get object schema with ETag-based caching.
+- `getView(object, type?)`: Get rendered view configuration.
### `client.data`
- `find(object, options)`: Advanced query with filtering, sorting, selection.
@@ -137,14 +137,12 @@ Initializes the client by fetching the system discovery manifest from `/api/v1`.
- `delete(object, id)`: Delete record.
- `deleteMany(object, ids[], options?)`: Batch delete records (convenience method).
-### `client.views` (New!)
-- `create(request)`: Create a new saved view.
-- `get(id)`: Get a saved view by ID.
-- `list(request?)`: List saved views with optional filters.
-- `update(request)`: Update an existing view.
-- `delete(id)`: Delete a saved view.
-- `share(id, userIds[])`: Share a view with users/teams.
-- `setDefault(id, object)`: Set a view as default for an object.
+### `client.views`
+- `create(object, data)`: Create a new saved view.
+- `get(object, viewId)`: Get a saved view by ID.
+- `list(object, type?)`: List saved views.
+- `update(object, viewId, data)`: Update an existing view.
+- `delete(object, viewId)`: Delete a saved view.
### Query Options
The `find` method accepts an options object with canonical field names:
@@ -226,25 +224,16 @@ const data = await retryableRequest(() =>
## Protocol Compliance
-The `@objectstack/client` SDK implements all 13 API namespaces defined in the `@objectstack/spec` protocol specification:
-
-| Namespace | Purpose | Status |
-|-----------|---------|:------:|
-| `discovery` | API version & capabilities detection | ✅ |
-| `meta` | Metadata operations (objects, plugins, etc.) | ✅ |
-| `data` | CRUD & query operations | ✅ |
-| `auth` | Authentication & user management | ✅ |
-| `packages` | Plugin/package lifecycle management | ✅ |
-| `views` | UI view definitions | ✅ |
-| `workflow` | Workflow state transitions | ✅ |
-| `analytics` | Analytics queries | ✅ |
-| `automation` | Automation triggers | ✅ |
-| `i18n` | Internationalization | ✅ |
-| `notifications` | Push notifications | ✅ |
-| `realtime` | WebSocket subscriptions | ✅ |
-| `ai` | AI services (NLQ, chat, insights) | ✅ |
-
-For detailed compliance verification, see [CLIENT_SPEC_COMPLIANCE.md](./CLIENT_SPEC_COMPLIANCE.md).
+The SDK ships 20 top-level surfaces: `meta`, `data`, `auth`, `oauth`,
+`organizations`, `projects`, `packages`, `views`, `permissions`, `workflow`,
+`approvals`, `analytics`, `automation`, `actions`, `storage`, `i18n`,
+`notifications`, `realtime`, `ai`, and `events`.
+
+Coverage against the server's actual route surface is no longer asserted by a
+hand-written table — it is audited and CI-enforced by the #3563 route ledger
+(`packages/runtime/src/route-ledger.ts` + its conformance tests, one half in
+runtime, one half in this package). See
+`docs/audits/2026-07-dispatcher-client-route-coverage.md` for the audit.
## Available Namespaces
@@ -291,8 +280,10 @@ await client.permissions.getEffectivePermissions();
await client.workflow.getConfig('approval');
await client.workflow.getState('approval', recordId);
await client.workflow.transition({ object: 'approval', recordId, transition: 'submit' });
-await client.workflow.approve({ object: 'approval', recordId });
-await client.workflow.reject({ object: 'approval', recordId, reason: 'Incomplete' });
+
+// Approvals (approve/reject live here, not on workflow)
+await client.approvals.approve(requestId, 'LGTM');
+await client.approvals.reject(requestId, 'Incomplete');
// Realtime
await client.realtime.connect({ protocol: 'websocket' });
@@ -306,7 +297,6 @@ await client.notifications.markRead(['notif-1', 'notif-2']);
// AI Services
await client.ai.nlq({ query: 'Show me all active contacts' });
-await client.ai.chat({ message: 'Summarize this project', context: projectId });
await client.ai.suggest({ object: 'contact', field: 'industry' });
await client.ai.insights({ object: 'sales', recordId: dealId });
diff --git a/packages/client/src/actions-surface.test.ts b/packages/client/src/actions-surface.test.ts
new file mode 100644
index 0000000000..3f2a627844
--- /dev/null
+++ b/packages/client/src/actions-surface.test.ts
@@ -0,0 +1,86 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `client.actions` — the SDK path to server-registered action handlers
+ * (`POST /api/v1/actions/...`), closing the largest #3563 gap: before this
+ * surface, the whole `/actions` domain was unreachable from the SDK.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { ObjectStackClient } from './index';
+
+function createMockClient(body: any, status = 200) {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: status === 200 ? 'OK' : 'Error',
+ json: async () => body,
+ headers: new Headers()
+ });
+ const client = new ObjectStackClient({
+ baseUrl: 'http://localhost:3000',
+ fetch: fetchMock
+ });
+ return { client, fetchMock };
+}
+
+describe('client.actions', () => {
+ it('invoke POSTs to /api/v1/actions/:object/:action with recordId + params in the body', async () => {
+ const { client, fetchMock } = createMockClient({
+ success: true,
+ data: { success: true, data: { converted: true } },
+ });
+
+ const result = await client.actions.invoke('crm_lead', 'convert', {
+ recordId: 'lead_1',
+ params: { create_opportunity: true },
+ });
+
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(String(url)).toBe('http://localhost:3000/api/v1/actions/crm_lead/convert');
+ expect(init.method).toBe('POST');
+ expect(JSON.parse(init.body)).toEqual({
+ recordId: 'lead_1',
+ params: { create_opportunity: true },
+ });
+ // unwrapResponse strips the transport envelope; the INNER envelope is
+ // the action handler's own result.
+ expect(result).toEqual({ success: true, data: { converted: true } });
+ });
+
+ it('invoke URL-encodes object and action names', async () => {
+ const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
+ await client.actions.invoke('my object', 'do/thing');
+ expect(String(fetchMock.mock.calls[0][0])).toBe(
+ 'http://localhost:3000/api/v1/actions/my%20object/do%2Fthing',
+ );
+ });
+
+ it('invoke defaults params to {} and omits recordId when not given', async () => {
+ const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
+ await client.actions.invoke('crm_lead', 'recalculate');
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ params: {} });
+ });
+
+ it('invokeGlobal targets the wildcard shape /actions/global/:action', async () => {
+ const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
+ await client.actions.invokeGlobal('nightly_cleanup', { params: { dryRun: true } });
+ expect(String(fetchMock.mock.calls[0][0])).toBe(
+ 'http://localhost:3000/api/v1/actions/global/nightly_cleanup',
+ );
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ params: { dryRun: true } });
+ });
+
+ it('surfaces the handler business failure without throwing (inner success:false)', async () => {
+ // The dispatcher reports handler throws as HTTP 200 with an inner
+ // `{ success:false, error }` — a toastable business failure, not a
+ // transport error (http-dispatcher.handleActions catch branch).
+ const { client } = createMockClient({
+ success: true,
+ data: { success: false, error: 'Lead is already converted' },
+ });
+ const result = await client.actions.invoke('crm_lead', 'convert', { recordId: 'lead_1' });
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Lead is already converted');
+ });
+});
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index 778e1caeba..cac6a9c35e 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -2355,6 +2355,61 @@ export class ObjectStackClient {
},
};
+ /**
+ * Server-registered Actions (#3563 gap closure)
+ *
+ * Dispatches named action handlers registered server-side via
+ * `engine.registerAction(objectName, actionName, handler)`. Until this
+ * surface existed, `POST /api/v1/actions/...` was entirely unreachable from
+ * the SDK — every console hand-rolled `fetch` for it (the largest functional
+ * hole found by the #3563 route audit).
+ *
+ * The path is fixed (`/api/v1/actions`), not discovery-routed: `actions` is
+ * not part of `ApiRoutesSchema`, so `getRoute()` cannot resolve it — same
+ * precedent as the `projects` surface's `/api/v1/cloud`.
+ *
+ * The dispatcher accepts the record id either in the URL or in the body;
+ * this client always sends it in the body (`{ recordId, params }`), which
+ * both server shapes honor. The HTTP envelope is
+ * `{ success, data: { success, data | error } }` — the OUTER envelope is
+ * transport success; the INNER `success:false` + `error` is the action
+ * handler's own (business) failure, deliberately not thrown so callers can
+ * surface it as a toast rather than a crash.
+ */
+ actions = {
+ /**
+ * Invoke a server-registered action on an object.
+ * Falls back to the server's wildcard ('*') handler when no
+ * object-specific handler is registered.
+ */
+ invoke: async (
+ objectName: string,
+ actionName: string,
+ opts?: { recordId?: string; params?: Record },
+ ): Promise<{ success: boolean; data?: T; error?: string }> => {
+ const res = await this.fetch(
+ `${this.baseUrl}/api/v1/actions/${encodeURIComponent(objectName)}/${encodeURIComponent(actionName)}`,
+ {
+ method: 'POST',
+ body: JSON.stringify({ recordId: opts?.recordId, params: opts?.params ?? {} }),
+ },
+ );
+ return this.unwrapResponse(res) as Promise<{ success: boolean; data?: T; error?: string }>;
+ },
+
+ /**
+ * Invoke a global (object-less) action — the server's
+ * `POST /actions/global/:action` shape, dispatched to the wildcard
+ * handler registry.
+ */
+ invokeGlobal: async (
+ actionName: string,
+ opts?: { recordId?: string; params?: Record },
+ ): Promise<{ success: boolean; data?: T; error?: string }> => {
+ return this.actions.invoke('global', actionName, opts);
+ },
+ };
+
/**
* Event Subscription API
* Provides real-time event subscriptions for metadata and data changes
diff --git a/packages/runtime/src/route-ledger.conformance.test.ts b/packages/runtime/src/route-ledger.conformance.test.ts
index ff055b3a93..bfdfc4de63 100644
--- a/packages/runtime/src/route-ledger.conformance.test.ts
+++ b/packages/runtime/src/route-ledger.conformance.test.ts
@@ -81,10 +81,10 @@ describe('route ledger hygiene', () => {
});
it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
- // Ratchet, not aspiration: 27 audited gaps at #3563 PR-1. Closing one =
- // reclassify the entry to `sdk` AND lower this bound. Raising it demands
- // an explicit, reviewed decision to ship a new un-expressed route.
+ // Ratchet, not aspiration: 27 audited gaps at #3563 PR-1; 24 after PR-2
+ // (actions surface). Closing a gap = reclassify to `sdk` AND lower this
+ // bound. Raising it demands an explicit, reviewed decision.
const gaps = ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
- expect(gaps).toBeLessThanOrEqual(27);
+ expect(gaps).toBeLessThanOrEqual(24);
});
});
diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts
index 99352d1c66..f5ae2337b4 100644
--- a/packages/runtime/src/route-ledger.ts
+++ b/packages/runtime/src/route-ledger.ts
@@ -189,10 +189,10 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
{ route: '* /mcp/**', domain: '/mcp', disposition: 'server-only', note: 'MCP Streamable HTTP transport — consumed by MCP clients, not this SDK' },
// ── actions ───────────────────────────────────────────────────────────────
- { route: 'POST /actions/:object/:action', domain: '/actions', disposition: 'gap',
- note: 'server-registered actions (engine.registerAction) are entirely unreachable from the SDK — the largest functional hole' },
- { route: 'POST /actions/:object/:action/:recordId', domain: '/actions', disposition: 'gap', note: 'record-scoped variant — same hole' },
- { route: 'POST /actions/global/:action', domain: '/actions', disposition: 'gap', note: 'wildcard variant — same hole' },
+ { route: 'POST /actions/:object/:action', domain: '/actions', disposition: 'sdk', client: 'actions.invoke' },
+ { route: 'POST /actions/:object/:action/:recordId', domain: '/actions', disposition: 'sdk', client: 'actions.invoke',
+ note: 'client sends recordId in the body — both server shapes honor it' },
+ { route: 'POST /actions/global/:action', domain: '/actions', disposition: 'sdk', client: 'actions.invokeGlobal' },
// ── misc legacy ───────────────────────────────────────────────────────────
{ route: 'GET /openapi.json', domain: '/openapi.json', disposition: 'server-only', note: 'docs tooling; falls through when metadata service lacks a generator' },