diff --git a/.changeset/adr-0033-blueprint-openai-strict.md b/.changeset/adr-0033-blueprint-openai-strict.md
new file mode 100644
index 000000000..319ab77ae
--- /dev/null
+++ b/.changeset/adr-0033-blueprint-openai-strict.md
@@ -0,0 +1,14 @@
+---
+"@objectstack/spec": patch
+"@objectstack/service-ai": patch
+---
+
+fix(ai): make ADR-0033 blueprint authoring work with OpenAI structured outputs
+
+Two bugs surfaced by a live end-to-end run (Studio chat → blueprint → draft → review → publish) against a real model (OpenAI via the Vercel AI Gateway) — both invisible to the existing unit tests:
+
+1. **`propose_blueprint` failed against OpenAI strict structured outputs.** `SolutionBlueprintSchema` uses optional fields and a free-form `seedData` record; OpenAI's strict mode requires every property listed in `required` and rejects open `additionalProperties`, so `generateObject` errored (`'required' … must include every key in properties`) and the agent silently fell back to free-text. Adds `SolutionBlueprintStrictSchema` — a strict-compatible mirror (optional → nullable, no `z.record`) used **only** as the `generateObject` output contract. The lenient `SolutionBlueprintSchema` (and every existing consumer/test) is unchanged; the blueprint tools strip the `null`s the strict contract emits so downstream stays clean.
+
+2. **Tool-only assistant turns failed to persist.** `ai_messages.content` is required, but an assistant turn that only calls a tool has no text, so the insert failed, the turn was dropped, and the next turn lost context (the agent re-proposed instead of applying the confirmed blueprint). `ObjectQLConversationService.addMessage` now synthesizes a readable placeholder from the tool names (`(called propose_blueprint)`) plus a defensive non-empty fallback.
+
+With both fixes the full plan-first loop runs end-to-end on OpenAI models: propose → confirm → batch-draft objects/views/dashboards/app → review/diff → publish.
diff --git a/content/docs/references/ai/index.mdx b/content/docs/references/ai/index.mdx
index ed17f6ddb..bd85150c2 100644
--- a/content/docs/references/ai/index.mdx
+++ b/content/docs/references/ai/index.mdx
@@ -14,6 +14,7 @@ This section contains all protocol schemas for the ai layer of ObjectStack.
+
diff --git a/content/docs/references/ai/meta.json b/content/docs/references/ai/meta.json
index 67ecc5df5..6f50bf15b 100644
--- a/content/docs/references/ai/meta.json
+++ b/content/docs/references/ai/meta.json
@@ -9,6 +9,7 @@
"mcp",
"model-registry",
"skill",
+ "solution-blueprint",
"tool",
"usage"
]
diff --git a/content/docs/references/ai/solution-blueprint.mdx b/content/docs/references/ai/solution-blueprint.mdx
new file mode 100644
index 000000000..c456cb56d
--- /dev/null
+++ b/content/docs/references/ai/solution-blueprint.mdx
@@ -0,0 +1,161 @@
+---
+title: Solution Blueprint
+description: Solution Blueprint protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs go in content/docs/guides/. */}
+
+Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring)
+
+The structured-output target an AI agent emits for a *high-level* goal
+
+("build me a project-management system") instead of transcribing a field
+
+list. It is a **simplified proposal shape** — deliberately lighter than the
+
+full [ObjectSchema](ObjectSchema) / [ViewSchema](ViewSchema) / [DashboardSchema](DashboardSchema).
+
+The `apply_blueprint` tool expands each entry into a proper metadata body
+
+and stages it as a draft (so the per-type Zod schema still validates the
+
+real artifact at write time).
+
+The blueprint is **never persisted on its own**: the agent presents it for
+
+conversational confirmation/edit (cheap), and only on human approval does it
+
+batch-draft. This is the safety valve for low-specificity input.
+
+
+**Source:** `packages/spec/src/ai/solution-blueprint.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { BlueprintApp, BlueprintDashboard, BlueprintField, BlueprintNavItem, BlueprintObject, BlueprintSeed, BlueprintView, SolutionBlueprint } from '@objectstack/spec/ai';
+import type { BlueprintApp, BlueprintDashboard, BlueprintField, BlueprintNavItem, BlueprintObject, BlueprintSeed, BlueprintView, SolutionBlueprint } from '@objectstack/spec/ai';
+
+// Validate data
+const result = BlueprintApp.parse(data);
+```
+
+---
+
+## BlueprintApp
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **name** | `string` | ✅ | App machine name (snake_case) |
+| **label** | `string` | optional | App display label |
+| **icon** | `string` | optional | Lucide icon for the App Launcher |
+| **nav** | `Object[]` | optional | Navigation entries; omit to auto-surface every created object and dashboard |
+
+
+---
+
+## BlueprintDashboard
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **name** | `string` | ✅ | Dashboard machine name (snake_case) |
+| **label** | `string` | optional | Human-readable dashboard label |
+| **widgets** | `Object[]` | optional | Widgets to place on the dashboard |
+
+
+---
+
+## BlueprintField
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **name** | `string` | ✅ | Field machine name (snake_case) |
+| **label** | `string` | optional | Human-readable field label |
+| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>` | ✅ | Field data type |
+| **required** | `boolean` | optional | Whether the field is required |
+| **reference** | `string` | optional | Target object name for lookup / master_detail relationship fields |
+| **options** | `Object[]` | optional | Choices for select / multiselect / radio fields |
+
+
+---
+
+## BlueprintNavItem
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **type** | `Enum<'object' \| 'dashboard'>` | ✅ | What this nav entry opens |
+| **target** | `string` | ✅ | Object or dashboard machine name to surface (snake_case) |
+| **label** | `string` | optional | Nav entry label (defaults to the target label/name) |
+| **icon** | `string` | optional | Lucide icon name for the nav entry |
+
+
+---
+
+## BlueprintObject
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **name** | `string` | ✅ | Object machine name (snake_case) |
+| **label** | `string` | optional | Human-readable singular label |
+| **description** | `string` | optional | What this object represents |
+| **fields** | `Object[]` | ✅ | Fields to create on the object |
+
+
+---
+
+## BlueprintSeed
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **object** | `string` | ✅ | Target object name (snake_case) |
+| **records** | `Record[]` | ✅ | Rows to seed |
+
+
+---
+
+## BlueprintView
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **object** | `string` | ✅ | Object this view displays (snake_case) |
+| **name** | `string` | ✅ | View machine name (snake_case) |
+| **label** | `string` | optional | Human-readable view label |
+| **type** | `Enum<'list' \| 'form' \| 'kanban' \| 'calendar'>` | ✅ | View kind |
+| **columns** | `string[]` | optional | Field names shown as columns (in order) |
+
+
+---
+
+## SolutionBlueprint
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **summary** | `string` | ✅ | One-line description of the proposed solution |
+| **assumptions** | `string[]` | ✅ | Design assumptions made from the underspecified goal |
+| **questions** | `string[]` | optional | At most 1-2 structure-deciding questions to confirm before building |
+| **objects** | `Object[]` | ✅ | Objects (tables) to create |
+| **views** | `Object[]` | optional | Views to create |
+| **dashboards** | `Object[]` | optional | Dashboards to create |
+| **app** | `Object` | optional | The navigation shell (app) that surfaces the created objects/dashboards to end users |
+| **seedData** | `Object[]` | optional | Suggested seed data (reported, not auto-applied in Phase C) |
+
+
+---
+
diff --git a/content/docs/references/api/discovery.mdx b/content/docs/references/api/discovery.mdx
index c818403af..2ac0fafa5 100644
--- a/content/docs/references/api/discovery.mdx
+++ b/content/docs/references/api/discovery.mdx
@@ -54,6 +54,7 @@ const result = ApiRoutes.parse(data);
| **graphql** | `string` | optional | e.g. /graphql |
| **packages** | `string` | optional | e.g. /api/v1/packages |
| **workflow** | `string` | optional | e.g. /api/v1/workflow |
+| **approvals** | `string` | optional | e.g. /api/v1/approvals |
| **realtime** | `string` | optional | e.g. /api/v1/realtime |
| **notifications** | `string` | optional | e.g. /api/v1/notifications |
| **ai** | `string` | optional | e.g. /api/v1/ai |
diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx
index ecb8f0ae1..f66a26b87 100644
--- a/content/docs/references/api/metadata.mdx
+++ b/content/docs/references/api/metadata.mdx
@@ -314,7 +314,7 @@ Metadata query with filtering, sorting, and pagination
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **types** | `Enum<'object' \| 'field' \| 'trigger' \| 'validation' \| 'hook' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'flow' \| 'workflow' \| 'approval' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'router' \| 'function' \| 'service' \| 'email_template' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
+| **types** | `Enum<'object' \| 'field' \| 'trigger' \| 'validation' \| 'hook' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'router' \| 'function' \| 'service' \| 'email_template' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
| **namespaces** | `string[]` | optional | Filter by namespaces |
| **packageId** | `string` | optional | Filter by owning package |
| **search** | `string` | optional | Full-text search query |
@@ -349,7 +349,7 @@ Metadata query with filtering, sorting, and pagination
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **type** | `Enum<'object' \| 'field' \| 'trigger' \| 'validation' \| 'hook' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'flow' \| 'workflow' \| 'approval' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'router' \| 'function' \| 'service' \| 'email_template' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type |
+| **type** | `Enum<'object' \| 'field' \| 'trigger' \| 'validation' \| 'hook' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'router' \| 'function' \| 'service' \| 'email_template' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type |
| **name** | `string` | ✅ | Item name (snake_case) |
| **data** | `Record` | ✅ | Metadata payload |
| **namespace** | `string` | optional | Optional namespace |
diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx
index 3d3af7576..a83b7d196 100644
--- a/content/docs/references/api/package-api.mdx
+++ b/content/docs/references/api/package-api.mdx
@@ -36,8 +36,8 @@ DELETE /api/v1/packages/:packageId — Uninstall a package
## TypeScript Usage
```typescript
-import { GetInstalledPackageRequest, GetInstalledPackageResponse, ListInstalledPackagesRequest, ListInstalledPackagesResponse, PackageApiErrorCode, PackageInstallRequest, PackageInstallResponse, PackagePathParams, PackageRollbackRequest, PackageRollbackResponse, PackageUpgradeRequest, PackageUpgradeResponse, ResolveDependenciesRequest, ResolveDependenciesResponse, UninstallPackageApiRequest, UninstallPackageApiResponse, UploadArtifactRequest, UploadArtifactResponse } from '@objectstack/spec/api';
-import type { GetInstalledPackageRequest, GetInstalledPackageResponse, ListInstalledPackagesRequest, ListInstalledPackagesResponse, PackageApiErrorCode, PackageInstallRequest, PackageInstallResponse, PackagePathParams, PackageRollbackRequest, PackageRollbackResponse, PackageUpgradeRequest, PackageUpgradeResponse, ResolveDependenciesRequest, ResolveDependenciesResponse, UninstallPackageApiRequest, UninstallPackageApiResponse, UploadArtifactRequest, UploadArtifactResponse } from '@objectstack/spec/api';
+import { GetInstalledPackageRequest, ListInstalledPackagesRequest, PackageApiErrorCode, PackagePathParams, PackageRollbackRequest, PackageRollbackResponse, PackageUpgradeResponse, ResolveDependenciesResponse, UninstallPackageApiRequest, UninstallPackageApiResponse, UploadArtifactRequest, UploadArtifactResponse } from '@objectstack/spec/api';
+import type { GetInstalledPackageRequest, ListInstalledPackagesRequest, PackageApiErrorCode, PackagePathParams, PackageRollbackRequest, PackageRollbackResponse, PackageUpgradeResponse, ResolveDependenciesResponse, UninstallPackageApiRequest, UninstallPackageApiResponse, UploadArtifactRequest, UploadArtifactResponse } from '@objectstack/spec/api';
// Validate data
const result = GetInstalledPackageRequest.parse(data);
@@ -54,22 +54,6 @@ const result = GetInstalledPackageRequest.parse(data);
| **packageId** | `string` | ✅ | Package identifier |
----
-
-## GetInstalledPackageResponse
-
-Get installed package response
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `Object` | optional | Error details if success is false |
-| **meta** | `Object` | optional | Response metadata |
-| **data** | `Object` | ✅ | Installed package details |
-
-
---
## ListInstalledPackagesRequest
@@ -86,22 +70,6 @@ List installed packages request
| **cursor** | `string` | optional | Cursor for pagination |
----
-
-## ListInstalledPackagesResponse
-
-List installed packages response
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `Object` | optional | Error details if success is false |
-| **meta** | `Object` | optional | Response metadata |
-| **data** | `Object` | ✅ | |
-
-
---
## PackageApiErrorCode
@@ -123,39 +91,6 @@ List installed packages response
* `upload_failed`
----
-
-## PackageInstallRequest
-
-Install package request
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **manifest** | `Object` | ✅ | Package manifest to install |
-| **settings** | `Record` | optional | User-provided settings at install time |
-| **enableOnInstall** | `boolean` | ✅ | Whether to enable immediately after install |
-| **platformVersion** | `string` | optional | Current platform version for compatibility verification |
-| **artifactRef** | `Object` | optional | Artifact reference for marketplace installation |
-
-
----
-
-## PackageInstallResponse
-
-Install package response
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `Object` | optional | Error details if success is false |
-| **meta** | `Object` | optional | Response metadata |
-| **data** | `Object` | ✅ | |
-
-
---
## PackagePathParams
@@ -198,25 +133,6 @@ Rollback package response
| **data** | `Object` | ✅ | |
----
-
-## PackageUpgradeRequest
-
-Upgrade package request
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **packageId** | `string` | ✅ | Package ID to upgrade |
-| **targetVersion** | `string` | optional | Target version (defaults to latest) |
-| **manifest** | `Object` | optional | New manifest for the target version |
-| **createSnapshot** | `boolean` | ✅ | Whether to create a pre-upgrade backup snapshot |
-| **mergeStrategy** | `Enum<'keep-custom' \| 'accept-incoming' \| 'three-way-merge'>` | ✅ | How to handle customer customizations |
-| **dryRun** | `boolean` | ✅ | Preview upgrade without making changes |
-| **skipValidation** | `boolean` | ✅ | Skip pre-upgrade compatibility checks |
-
-
---
## PackageUpgradeResponse
@@ -233,20 +149,6 @@ Upgrade package response
| **data** | `Object` | ✅ | |
----
-
-## ResolveDependenciesRequest
-
-Resolve dependencies request
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **manifest** | `Object` | ✅ | Package manifest to resolve dependencies for |
-| **platformVersion** | `string` | optional | Current platform version for compatibility filtering |
-
-
---
## ResolveDependenciesResponse
diff --git a/content/docs/references/api/package-registry.mdx b/content/docs/references/api/package-registry.mdx
index 1cc4e19e7..fb6757a23 100644
--- a/content/docs/references/api/package-registry.mdx
+++ b/content/docs/references/api/package-registry.mdx
@@ -12,8 +12,8 @@ description: Package Registry protocol schemas
## TypeScript Usage
```typescript
-import { DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, GetPackageRequest, GetPackageResponse, InstallPackageRequest, InstallPackageResponse, ListPackagesRequest, ListPackagesResponse, UninstallPackageRequest, UninstallPackageResponse } from '@objectstack/spec/api';
-import type { DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, GetPackageRequest, GetPackageResponse, InstallPackageRequest, InstallPackageResponse, ListPackagesRequest, ListPackagesResponse, UninstallPackageRequest, UninstallPackageResponse } from '@objectstack/spec/api';
+import { DisablePackageRequest, EnablePackageRequest, GetPackageRequest, ListPackagesRequest, UninstallPackageRequest, UninstallPackageResponse } from '@objectstack/spec/api';
+import type { DisablePackageRequest, EnablePackageRequest, GetPackageRequest, ListPackagesRequest, UninstallPackageRequest, UninstallPackageResponse } from '@objectstack/spec/api';
// Validate data
const result = DisablePackageRequest.parse(data);
@@ -32,20 +32,6 @@ Disable package request
| **id** | `string` | ✅ | Package ID to disable |
----
-
-## DisablePackageResponse
-
-Disable package response
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **package** | `Object` | ✅ | Disabled package details |
-| **message** | `string` | optional | Disable status message |
-
-
---
## EnablePackageRequest
@@ -59,20 +45,6 @@ Enable package request
| **id** | `string` | ✅ | Package ID to enable |
----
-
-## EnablePackageResponse
-
-Enable package response
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **package** | `Object` | ✅ | Enabled package details |
-| **message** | `string` | optional | Enable status message |
-
-
---
## GetPackageRequest
@@ -86,50 +58,6 @@ Get package request
| **id** | `string` | ✅ | Package identifier |
----
-
-## GetPackageResponse
-
-Get package response
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **package** | `Object` | ✅ | Package details |
-
-
----
-
-## InstallPackageRequest
-
-Install package request
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **manifest** | `Object` | ✅ | Package manifest to install |
-| **settings** | `Record` | optional | User-provided settings at install time |
-| **enableOnInstall** | `boolean` | ✅ | Whether to enable immediately after install |
-| **platformVersion** | `string` | optional | Current platform version for compatibility verification |
-
-
----
-
-## InstallPackageResponse
-
-Install package response
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **package** | `Object` | ✅ | Installed package details |
-| **message** | `string` | optional | Installation status message |
-| **dependencyResolution** | `Object` | optional | Dependency resolution result from install analysis |
-
-
---
## ListPackagesRequest
@@ -145,20 +73,6 @@ List packages request
| **enabled** | `boolean` | optional | Filter by enabled state |
----
-
-## ListPackagesResponse
-
-List packages response
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **packages** | `Object[]` | ✅ | List of installed packages |
-| **total** | `number` | ✅ | Total package count |
-
-
---
## UninstallPackageRequest
diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx
index a1ea17842..94d2d35ae 100644
--- a/content/docs/references/api/protocol.mdx
+++ b/content/docs/references/api/protocol.mdx
@@ -20,8 +20,8 @@ validation. Each entry is a canonical [ActionDescriptorSchema](ActionDescriptorS
## TypeScript Usage
```typescript
-import { AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetViewRequest, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, WorkflowApproveRequest, WorkflowApproveResponse, WorkflowRejectRequest, WorkflowRejectResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api';
-import type { AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetViewRequest, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, WorkflowApproveRequest, WorkflowApproveResponse, WorkflowRejectRequest, WorkflowRejectResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api';
+import { AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetViewRequest, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api';
+import type { AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetViewRequest, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api';
// Validate data
const result = AiInsightsRequest.parse(data);
@@ -1163,62 +1163,6 @@ const result = AiInsightsRequest.parse(data);
| **preferences** | `Object` | ✅ | Updated notification preferences |
----
-
-## WorkflowApproveRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name |
-| **recordId** | `string` | ✅ | Record ID |
-| **comment** | `string` | optional | Approval comment |
-| **data** | `Record` | optional | Additional data |
-
-
----
-
-## WorkflowApproveResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name |
-| **recordId** | `string` | ✅ | Record ID |
-| **success** | `boolean` | ✅ | Whether the approval succeeded |
-| **state** | `Object` | ✅ | New workflow state after approval |
-
-
----
-
-## WorkflowRejectRequest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name |
-| **recordId** | `string` | ✅ | Record ID |
-| **reason** | `string` | ✅ | Rejection reason |
-| **comment** | `string` | optional | Additional comment |
-
-
----
-
-## WorkflowRejectResponse
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name |
-| **recordId** | `string` | ✅ | Record ID |
-| **success** | `boolean` | ✅ | Whether the rejection succeeded |
-| **state** | `Object` | ✅ | New workflow state after rejection |
-
-
---
## WorkflowState
diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx
index 8a043f1a9..de2ccf3d8 100644
--- a/content/docs/references/automation/flow.mdx
+++ b/content/docs/references/automation/flow.mdx
@@ -73,7 +73,9 @@ const result = FlowNode.parse(data);
* `update_record`
* `delete_record`
* `get_record`
+* `http`
* `http_request`
+* `notify`
* `script`
* `screen`
* `wait`
diff --git a/content/docs/references/automation/job.mdx b/content/docs/references/automation/job.mdx
new file mode 100644
index 000000000..86804cc9a
--- /dev/null
+++ b/content/docs/references/automation/job.mdx
@@ -0,0 +1,38 @@
+---
+title: Job
+description: Job protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs go in content/docs/guides/. */}
+
+
+**Source:** `packages/spec/src/automation/job.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { RetryPolicy } from '@objectstack/spec/automation';
+import type { RetryPolicy } from '@objectstack/spec/automation';
+
+// Validate data
+const result = RetryPolicy.parse(data);
+```
+
+---
+
+## RetryPolicy
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **maxRetries** | `integer` | ✅ | Retry attempts before giving up |
+| **retryDelayMs** | `integer` | ✅ | Base delay between retries (ms) |
+| **backoffMultiplier** | `number` | ✅ | Exponential backoff multiplier |
+| **maxRetryDelayMs** | `integer` | ✅ | Maximum delay between retries (ms) |
+| **jitter** | `boolean` | ✅ | Add random jitter to retry delay |
+
+
+---
+
diff --git a/content/docs/references/automation/meta.json b/content/docs/references/automation/meta.json
index 41d03c763..986231e84 100644
--- a/content/docs/references/automation/meta.json
+++ b/content/docs/references/automation/meta.json
@@ -7,6 +7,7 @@
"etl",
"execution",
"flow",
+ "job",
"node-executor",
"offline",
"state-machine",
diff --git a/content/docs/references/cloud/marketplace.mdx b/content/docs/references/cloud/marketplace.mdx
index 05e6d8894..9e8f3d4fa 100644
--- a/content/docs/references/cloud/marketplace.mdx
+++ b/content/docs/references/cloud/marketplace.mdx
@@ -200,6 +200,7 @@ Public-facing package listing on the marketplace
| :--- | :--- | :--- | :--- |
| **id** | `string` | ✅ | Listing ID (matches package manifest ID) |
| **packageId** | `string` | ✅ | Package identifier |
+| **packageType** | `Enum<'app'>` | ✅ | Consumer-installable package type (ADR-0019: only `app` is listable) |
| **publisherId** | `string` | ✅ | Publisher ID |
| **status** | `Enum<'draft' \| 'submitted' \| 'in-review' \| 'approved' \| 'published' \| 'rejected' \| 'suspended' \| 'deprecated' \| 'unlisted'>` | ✅ | Publication state: draft, published, under-review, suspended, deprecated, or unlisted |
| **name** | `string` | ✅ | Display name |
diff --git a/content/docs/references/data/validation.mdx b/content/docs/references/data/validation.mdx
index fa718773c..9f6f1728a 100644
--- a/content/docs/references/data/validation.mdx
+++ b/content/docs/references/data/validation.mdx
@@ -15,23 +15,51 @@ type-safe validation system similar to Salesforce's validation rules but with en
Validation rules are applied at the data layer to ensure data integrity and enforce business logic.
-The system supports multiple validation types:
+A validation rule is a **deterministic, synchronous, side-effect-free predicate over a single
-1. **Script Validation**: Formula-based validation using expressions
+record** — it must be decidable from the incoming write (and, on update, the prior record) with
-2. **Uniqueness Validation**: Enforce unique constraints across fields
+no I/O. Everything advertised here runs on the write path (see
-3. **State Machine Validation**: Control allowed state transitions
+`objectql/src/validation/rule-validator.ts`); nothing is a silent no-op.
-4. **Format Validation**: Validate field formats (email, URL, regex, etc.)
+The system supports these validation types:
-5. **Cross-Field Validation**: Validate relationships between multiple fields
+1. **Script Validation**: Formula-based validation using a CEL predicate
-6. **Async Validation**: Remote validation via API calls
+2. **State Machine Validation**: Control allowed state transitions
-7. **Custom Validation**: User-defined validation functions
+3. **Format Validation**: Validate a field's value (email, URL, phone, JSON, regex)
-8. **Conditional Validation**: Apply validations based on conditions
+4. **Cross-Field Validation**: Validate relationships between multiple fields
+
+5. **JSON Schema Validation**: Validate a JSON field against a JSON Schema
+
+6. **Conditional Validation**: Apply a nested rule based on a CEL condition
+
+## Deliberately NOT validation rules
+
+These were once declared here but never enforced. Because the contract above rules them out
+
+(they need I/O or are client-side concerns), they were removed rather than left as silent
+
+no-ops. Use the layer that already does each one correctly:
+
+- **Uniqueness** → a unique **index** (`ObjectSchema.indexes`, `\{ fields, unique: true \}`,
+
+with `partial` for a scoped/conditional constraint), or field-level `unique: true`. A
+
+SELECT-then-INSERT "rule" is inherently racy (TOCTOU); a DB unique constraint is not.
+
+- **Async / remote validation** → a client-form concern (`debounce`/`validatorUrl` only mean
+
+anything against keystrokes) and an SSRF/latency hazard on the server write path. Keep it in
+
+the form layer, or enforce the underlying invariant with a `unique` index / lifecycle hook.
+
+- **Custom handler** → a `beforeInsert` / `beforeUpdate` lifecycle hook, the typed, supported
+
+extension point for arbitrary validation code.
## Salesforce Comparison
@@ -80,63 +108,13 @@ severity: 'error'
## TypeScript Usage
```typescript
-import { AsyncValidation, CustomValidator, FormatValidation, JSONValidation, StateMachineValidation } from '@objectstack/spec/data';
-import type { AsyncValidation, CustomValidator, FormatValidation, JSONValidation, StateMachineValidation } from '@objectstack/spec/data';
+import { FormatValidation, JSONValidation, StateMachineValidation } from '@objectstack/spec/data';
+import type { FormatValidation, JSONValidation, StateMachineValidation } from '@objectstack/spec/data';
// Validate data
-const result = AsyncValidation.parse(data);
+const result = FormatValidation.parse(data);
```
----
-
-## AsyncValidation
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **name** | `string` | ✅ | Unique rule name (snake_case) |
-| **label** | `string` | optional | Human-readable label for the rule listing |
-| **description** | `string` | optional | Administrative notes explaining the business reason |
-| **active** | `boolean` | ✅ | |
-| **events** | `Enum<'insert' \| 'update' \| 'delete'>[]` | ✅ | Validation contexts |
-| **priority** | `integer` | ✅ | Execution priority (lower runs first, default: 100) |
-| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
-| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | ✅ | |
-| **message** | `string` | ✅ | Error message to display to the user |
-| **type** | `string` | ✅ | |
-| **field** | `string` | ✅ | Field to validate |
-| **validatorUrl** | `string` | optional | External API endpoint for validation |
-| **method** | `Enum<'GET' \| 'POST'>` | ✅ | HTTP method for external call |
-| **headers** | `Record` | optional | Custom headers for the request |
-| **validatorFunction** | `string` | optional | Reference to custom validator function |
-| **timeout** | `number` | ✅ | Timeout in milliseconds |
-| **debounce** | `number` | optional | Debounce delay in milliseconds |
-| **params** | `Record` | optional | Additional parameters to pass to validator |
-
-
----
-
-## CustomValidator
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **name** | `string` | ✅ | Unique rule name (snake_case) |
-| **label** | `string` | optional | Human-readable label for the rule listing |
-| **description** | `string` | optional | Administrative notes explaining the business reason |
-| **active** | `boolean` | ✅ | |
-| **events** | `Enum<'insert' \| 'update' \| 'delete'>[]` | ✅ | Validation contexts |
-| **priority** | `integer` | ✅ | Execution priority (lower runs first, default: 100) |
-| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
-| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | ✅ | |
-| **message** | `string` | ✅ | Error message to display to the user |
-| **type** | `string` | ✅ | |
-| **handler** | `string` | ✅ | Name of the custom validation function registered in the system |
-| **params** | `Record` | optional | Parameters passed to the custom handler |
-
-
---
## FormatValidation
diff --git a/content/docs/references/kernel/index.mdx b/content/docs/references/kernel/index.mdx
index d5905fccf..e0d642a05 100644
--- a/content/docs/references/kernel/index.mdx
+++ b/content/docs/references/kernel/index.mdx
@@ -11,7 +11,6 @@ This section contains all protocol schemas for the kernel layer of ObjectStack.
-
diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx
index d6af4d22b..0a5173d0d 100644
--- a/content/docs/references/kernel/manifest.mdx
+++ b/content/docs/references/kernel/manifest.mdx
@@ -5,35 +5,23 @@ description: Manifest protocol schemas
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs go in content/docs/guides/. */}
-Schema for the ObjectStack Manifest.
+Structured permission grants requested by a plugin (ADR-0025 §3.2).
-This defines the structure of a package configuration in the ObjectStack ecosystem.
+Each list scopes one capability surface the plugin may touch. The
-All packages (apps, plugins, drivers, modules) must conform to this schema.
+install-time consent flow (ADR §3.5 step 2) turns this declaration into
-@example App Package
+the persisted `granted_permissions` set enforced at load by the
-```yaml
+PluginPermissionEnforcer.
-id: com.acme.crm
+@example
-version: 1.0.0
+```jsonc
-type: app
+\{ "services": ["object", "http"], "hooks": ["record.beforeInsert"],
-name: Acme CRM
-
-description: Customer Relationship Management system
-
-permissions:
-
-- system.user.read
-
-- system.object.create
-
-objects:
-
-- "./src/objects/*.object.yml"
+"network": ["api.acme.com"], "fs": [] \}
```
@@ -44,40 +32,99 @@ objects:
## TypeScript Usage
```typescript
-import { Manifest } from '@objectstack/spec/kernel';
-import type { Manifest } from '@objectstack/spec/kernel';
+import { ManifestPermissions, PluginEngines, PluginIntegrity, PluginPackaging, PluginPermissions, PluginRuntime } from '@objectstack/spec/kernel';
+import type { ManifestPermissions, PluginEngines, PluginIntegrity, PluginPackaging, PluginPermissions, PluginRuntime } from '@objectstack/spec/kernel';
// Validate data
-const result = Manifest.parse(data);
+const result = ManifestPermissions.parse(data);
```
---
-## Manifest
+## ManifestPermissions
+
+### Union Options
+
+This schema accepts one of the following structures:
+
+#### Option 1
+
+Type: `string[]`
+
+---
+
+#### Option 2
+
+Structured plugin permission grants (ADR-0025 §3.2)
### Properties
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **id** | `string` | ✅ | Unique package identifier (reverse domain style) |
-| **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") |
-| **defaultDatasource** | `string` | ✅ | Default datasource for all objects in this package |
-| **version** | `string` | ✅ | Package version (semantic versioning) |
-| **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| 'module' \| 'gateway' \| 'adapter'>` | ✅ | Type of package |
-| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | ✅ | Deployment scope: cloud | system | project |
-| **name** | `string` | ✅ | Human-readable package name |
-| **description** | `string` | optional | Package description |
-| **permissions** | `string[]` | optional | Array of required permission strings |
-| **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files |
-| **datasources** | `string[]` | optional | Glob patterns for Datasource definitions |
-| **dependencies** | `Record` | optional | Package dependencies |
-| **configuration** | `Object` | optional | Plugin configuration settings |
-| **contributes** | `Object` | optional | Platform contributions |
-| **data** | `Object[]` | optional | Initial seed data (prefer top-level data field) |
-| **capabilities** | `Object` | optional | Plugin capability declarations for interoperability |
-| **extensions** | `Record` | optional | Extension points and contributions |
-| **loading** | `Object` | optional | Plugin loading and runtime behavior configuration |
-| **engine** | `Object` | optional | Platform compatibility requirements |
+| **services** | `string[]` | optional | Platform services the plugin may resolve (e.g. "object", "http") |
+| **hooks** | `string[]` | optional | Lifecycle hooks the plugin may register (e.g. "record.beforeInsert") |
+| **network** | `string[]` | optional | Network hosts the plugin may reach (e.g. "api.acme.com") |
+| **fs** | `string[]` | optional | Filesystem paths the plugin may access |
+
+---
+
+
+---
+
+## PluginEngines
+
+Plugin compatibility ranges (ADR-0025 §3.2)
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **platform** | `string` | optional | ObjectStack platform release range (SemVer, e.g. ">=4.0 <5") |
+| **protocol** | `string` | optional | Runtime/metadata protocol range, checked first (ADR §3.10 #3) |
+
+
+---
+
+
+---
+
+## PluginPackaging
+
+Dependency packaging strategy (ADR-0025 §3.3)
+
+### Allowed Values
+
+* `bundled`
+* `manifest-deps`
+
+
+---
+
+## PluginPermissions
+
+Structured plugin permission grants (ADR-0025 §3.2)
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **services** | `string[]` | optional | Platform services the plugin may resolve (e.g. "object", "http") |
+| **hooks** | `string[]` | optional | Lifecycle hooks the plugin may register (e.g. "record.beforeInsert") |
+| **network** | `string[]` | optional | Network hosts the plugin may reach (e.g. "api.acme.com") |
+| **fs** | `string[]` | optional | Filesystem paths the plugin may access |
+
+
+---
+
+## PluginRuntime
+
+Plugin trust tier (ADR-0025 §3.6)
+
+### Allowed Values
+
+* `node`
+* `sandbox`
+* `worker`
---
diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx
index 589b89095..283f830f2 100644
--- a/content/docs/references/kernel/metadata-plugin.mdx
+++ b/content/docs/references/kernel/metadata-plugin.mdx
@@ -130,7 +130,7 @@ const result = MetadataBulkRegisterRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **event** | `Enum<'metadata.registered' \| 'metadata.updated' \| 'metadata.unregistered' \| 'metadata.validated' \| 'metadata.deployed' \| 'metadata.overlay.applied' \| 'metadata.overlay.removed' \| 'metadata.imported' \| 'metadata.exported'>` | ✅ | Event type |
-| **metadataType** | `Enum<'object' \| 'field' \| 'trigger' \| 'validation' \| 'hook' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'flow' \| 'workflow' \| 'approval' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'router' \| 'function' \| 'service' \| 'email_template' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type |
+| **metadataType** | `Enum<'object' \| 'field' \| 'trigger' \| 'validation' \| 'hook' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'router' \| 'function' \| 'service' \| 'email_template' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>` | ✅ | Metadata type |
| **name** | `string` | ✅ | Metadata item name |
| **namespace** | `string` | optional | Namespace |
| **packageId** | `string` | optional | Owning package ID |
@@ -147,7 +147,7 @@ const result = MetadataBulkRegisterRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **types** | `Enum<'object' \| 'field' \| 'trigger' \| 'validation' \| 'hook' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'flow' \| 'workflow' \| 'approval' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'router' \| 'function' \| 'service' \| 'email_template' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
+| **types** | `Enum<'object' \| 'field' \| 'trigger' \| 'validation' \| 'hook' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'router' \| 'function' \| 'service' \| 'email_template' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
| **namespaces** | `string[]` | optional | Filter by namespaces |
| **packageId** | `string` | optional | Filter by owning package |
| **search** | `string` | optional | Full-text search query |
@@ -192,8 +192,6 @@ const result = MetadataBulkRegisterRequest.parse(data);
* `action`
* `report`
* `flow`
-* `workflow`
-* `approval`
* `job`
* `datasource`
* `external_catalog`
diff --git a/content/docs/references/kernel/package-registry.mdx b/content/docs/references/kernel/package-registry.mdx
index b02f7bf61..e19d55e9b 100644
--- a/content/docs/references/kernel/package-registry.mdx
+++ b/content/docs/references/kernel/package-registry.mdx
@@ -9,21 +9,27 @@ description: Package Registry protocol schemas
Defines the runtime state and lifecycle operations for installed packages.
-## Key Distinction: Package vs App
+## Key Distinction: App vs Package (ADR-0019)
-- **Package (Manifest)**: The unit of installation — a deployable artifact containing
+- **App (AppSchema)**: the one consumer-facing unit — what a tenant downloads,
-metadata (objects, actions, flows, etc.) and optionally one or more Apps.
+opens, and uninstalls. Only `type: app` packages are consumer-installable
-- **App (AppSchema)**: A UI navigation shell defined inside a package.
+(see `isConsumerInstallable`), and a consumer package defines **at most one
-A package may contain:
+app** — there is no "suite contains apps" aggregator.
-- Zero apps (pure functionality plugin, e.g. a storage driver)
+- **Package (Manifest)**: the internal / control-plane artifact term (the
-- One app (typical business application)
+"row" in the installed-packages table). Never surfaced to consumers as a
-- Multiple apps (suite of applications)
+separate noun.
+
+- **Internal contributions** (plugin/driver/server/…): the "frameworks inside
+
+the .app bundle" — bundled within an App or operator-provisioned; a consumer
+
+never installs them directly.
## Architecture Alignment
@@ -42,8 +48,8 @@ A package may contain:
## TypeScript Usage
```typescript
-import { DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, GetPackageRequest, GetPackageResponse, InstallPackageRequest, InstallPackageResponse, InstalledPackage, ListPackagesRequest, ListPackagesResponse, NamespaceConflictError, NamespaceRegistryEntry, PackageStatusEnum, UninstallPackageRequest, UninstallPackageResponse } from '@objectstack/spec/kernel';
-import type { DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, GetPackageRequest, GetPackageResponse, InstallPackageRequest, InstallPackageResponse, InstalledPackage, ListPackagesRequest, ListPackagesResponse, NamespaceConflictError, NamespaceRegistryEntry, PackageStatusEnum, UninstallPackageRequest, UninstallPackageResponse } from '@objectstack/spec/kernel';
+import { DisablePackageRequest, EnablePackageRequest, GetPackageRequest, ListPackagesRequest, NamespaceConflictError, NamespaceRegistryEntry, PackageStatusEnum, UninstallPackageRequest, UninstallPackageResponse } from '@objectstack/spec/kernel';
+import type { DisablePackageRequest, EnablePackageRequest, GetPackageRequest, ListPackagesRequest, NamespaceConflictError, NamespaceRegistryEntry, PackageStatusEnum, UninstallPackageRequest, UninstallPackageResponse } from '@objectstack/spec/kernel';
// Validate data
const result = DisablePackageRequest.parse(data);
@@ -62,20 +68,6 @@ Disable package request
| **id** | `string` | ✅ | Package ID to disable |
----
-
-## DisablePackageResponse
-
-Disable package response
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **package** | `Object` | ✅ | Disabled package details |
-| **message** | `string` | optional | Disable status message |
-
-
---
## EnablePackageRequest
@@ -89,20 +81,6 @@ Enable package request
| **id** | `string` | ✅ | Package ID to enable |
----
-
-## EnablePackageResponse
-
-Enable package response
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **package** | `Object` | ✅ | Enabled package details |
-| **message** | `string` | optional | Enable status message |
-
-
---
## GetPackageRequest
@@ -116,74 +94,6 @@ Get package request
| **id** | `string` | ✅ | Package identifier |
----
-
-## GetPackageResponse
-
-Get package response
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **package** | `Object` | ✅ | Package details |
-
-
----
-
-## InstallPackageRequest
-
-Install package request
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **manifest** | `Object` | ✅ | Package manifest to install |
-| **settings** | `Record` | optional | User-provided settings at install time |
-| **enableOnInstall** | `boolean` | ✅ | Whether to enable immediately after install |
-| **platformVersion** | `string` | optional | Current platform version for compatibility verification |
-
-
----
-
-## InstallPackageResponse
-
-Install package response
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **package** | `Object` | ✅ | Installed package details |
-| **message** | `string` | optional | Installation status message |
-| **dependencyResolution** | `Object` | optional | Dependency resolution result from install analysis |
-
-
----
-
-## InstalledPackage
-
-Installed package with runtime lifecycle state
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **manifest** | `Object` | ✅ | Full package manifest |
-| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | ✅ | Package state: installed, disabled, installing, upgrading, uninstalling, or error |
-| **enabled** | `boolean` | ✅ | Whether the package is currently enabled |
-| **installedAt** | `string` | optional | Installation timestamp |
-| **updatedAt** | `string` | optional | Last update timestamp |
-| **installedVersion** | `string` | optional | Currently installed version for quick access |
-| **previousVersion** | `string` | optional | Version before the last upgrade |
-| **statusChangedAt** | `string` | optional | Status change timestamp |
-| **errorMessage** | `string` | optional | Error message when status is error |
-| **settings** | `Record` | optional | User-provided configuration settings |
-| **upgradeHistory** | `Object[]` | optional | Version upgrade history |
-| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package |
-
-
---
## ListPackagesRequest
@@ -199,20 +109,6 @@ List packages request
| **enabled** | `boolean` | optional | Filter by enabled state |
----
-
-## ListPackagesResponse
-
-List packages response
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **packages** | `Object[]` | ✅ | List of installed packages |
-| **total** | `number` | ✅ | Total package count |
-
-
---
## NamespaceConflictError
diff --git a/content/docs/references/kernel/package-upgrade.mdx b/content/docs/references/kernel/package-upgrade.mdx
index 23a471a72..4d998b3b9 100644
--- a/content/docs/references/kernel/package-upgrade.mdx
+++ b/content/docs/references/kernel/package-upgrade.mdx
@@ -48,8 +48,8 @@ and rollback capabilities.
## TypeScript Usage
```typescript
-import { MetadataChangeType, MetadataDiffItem, RollbackPackageRequest, RollbackPackageResponse, UpgradeImpactLevel, UpgradePackageRequest, UpgradePackageResponse, UpgradePhase, UpgradePlan, UpgradeSnapshot } from '@objectstack/spec/kernel';
-import type { MetadataChangeType, MetadataDiffItem, RollbackPackageRequest, RollbackPackageResponse, UpgradeImpactLevel, UpgradePackageRequest, UpgradePackageResponse, UpgradePhase, UpgradePlan, UpgradeSnapshot } from '@objectstack/spec/kernel';
+import { MetadataChangeType, MetadataDiffItem, RollbackPackageRequest, RollbackPackageResponse, UpgradeImpactLevel, UpgradePackageResponse, UpgradePhase, UpgradePlan } from '@objectstack/spec/kernel';
+import type { MetadataChangeType, MetadataDiffItem, RollbackPackageRequest, RollbackPackageResponse, UpgradeImpactLevel, UpgradePackageResponse, UpgradePhase, UpgradePlan } from '@objectstack/spec/kernel';
// Validate data
const result = MetadataChangeType.parse(data);
@@ -132,25 +132,6 @@ Severity of upgrade impact
* `critical`
----
-
-## UpgradePackageRequest
-
-Upgrade package request
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **packageId** | `string` | ✅ | Package ID to upgrade |
-| **targetVersion** | `string` | optional | Target version (defaults to latest) |
-| **manifest** | `Object` | optional | New manifest (if installing from local) |
-| **createSnapshot** | `boolean` | ✅ | Whether to create a pre-upgrade backup snapshot |
-| **mergeStrategy** | `Enum<'keep-custom' \| 'accept-incoming' \| 'three-way-merge'>` | ✅ | How to handle customer customizations |
-| **dryRun** | `boolean` | ✅ | Preview upgrade without making changes |
-| **skipValidation** | `boolean` | ✅ | Skip pre-upgrade compatibility checks |
-
-
---
## UpgradePackageResponse
@@ -215,25 +196,3 @@ Upgrade analysis plan generated before execution
---
-## UpgradeSnapshot
-
-Pre-upgrade state snapshot for rollback capability
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **id** | `string` | ✅ | Snapshot identifier |
-| **packageId** | `string` | ✅ | Package identifier |
-| **fromVersion** | `string` | ✅ | Version before upgrade |
-| **toVersion** | `string` | ✅ | Target upgrade version |
-| **tenantId** | `string` | optional | Tenant identifier |
-| **previousManifest** | `Object` | ✅ | Complete manifest of the previous package version |
-| **metadataSnapshot** | `Object[]` | ✅ | Snapshot of all package metadata |
-| **customizationSnapshot** | `Record[]` | optional | Snapshot of customer customizations |
-| **createdAt** | `string` | ✅ | Snapshot creation timestamp |
-| **expiresAt** | `string` | optional | Snapshot expiry timestamp |
-
-
----
-
diff --git a/content/docs/references/shared/index.mdx b/content/docs/references/shared/index.mdx
index 4856d55bf..b470bf8ad 100644
--- a/content/docs/references/shared/index.mdx
+++ b/content/docs/references/shared/index.mdx
@@ -7,15 +7,10 @@ This section contains all protocol schemas for the shared layer of ObjectStack.
-
-
-
-
-
diff --git a/content/docs/references/ui/chart.mdx b/content/docs/references/ui/chart.mdx
index 769498363..fef917664 100644
--- a/content/docs/references/ui/chart.mdx
+++ b/content/docs/references/ui/chart.mdx
@@ -69,7 +69,7 @@ const result = ChartAnnotation.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'grouped-bar' \| 'stacked-bar' \| 'bi-polar-bar' \| 'line' \| 'area' \| 'stacked-area' \| 'step-line' \| 'spline' \| 'pie' \| 'donut' \| 'funnel' \| 'pyramid' \| 'scatter' \| 'bubble' \| 'treemap' \| 'sunburst' \| 'sankey' \| 'word-cloud' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'choropleth' \| 'bubble-map' \| 'gl-map' \| 'heatmap' \| 'radar' \| 'waterfall' \| 'box-plot' \| 'violin' \| 'candlestick' \| 'stock' \| 'table' \| 'pivot'>` | ✅ | |
+| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'grouped-bar' \| 'stacked-bar' \| 'bi-polar-bar' \| 'line' \| 'area' \| 'stacked-area' \| 'step-line' \| 'spline' \| 'pie' \| 'donut' \| 'funnel' \| 'pyramid' \| 'scatter' \| 'bubble' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>` | ✅ | |
| **title** | `string` | optional | Chart title |
| **subtitle** | `string` | optional | Chart subtitle |
| **description** | `string` | optional | Accessibility description |
@@ -109,7 +109,7 @@ const result = ChartAnnotation.parse(data);
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Field name or series identifier |
| **label** | `string` | optional | Series display label |
-| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'grouped-bar' \| 'stacked-bar' \| 'bi-polar-bar' \| 'line' \| 'area' \| 'stacked-area' \| 'step-line' \| 'spline' \| 'pie' \| 'donut' \| 'funnel' \| 'pyramid' \| 'scatter' \| 'bubble' \| 'treemap' \| 'sunburst' \| 'sankey' \| 'word-cloud' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'choropleth' \| 'bubble-map' \| 'gl-map' \| 'heatmap' \| 'radar' \| 'waterfall' \| 'box-plot' \| 'violin' \| 'candlestick' \| 'stock' \| 'table' \| 'pivot'>` | optional | Override chart type for this series |
+| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'grouped-bar' \| 'stacked-bar' \| 'bi-polar-bar' \| 'line' \| 'area' \| 'stacked-area' \| 'step-line' \| 'spline' \| 'pie' \| 'donut' \| 'funnel' \| 'pyramid' \| 'scatter' \| 'bubble' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>` | optional | Override chart type for this series |
| **color** | `string` | optional | Series color (hex/rgb/token) |
| **stack** | `string` | optional | Stack identifier to group series |
| **yAxis** | `Enum<'left' \| 'right'>` | ✅ | Bind to specific Y-Axis |
@@ -142,24 +142,13 @@ const result = ChartAnnotation.parse(data);
* `scatter`
* `bubble`
* `treemap`
-* `sunburst`
* `sankey`
-* `word-cloud`
* `gauge`
* `solid-gauge`
* `metric`
* `kpi`
* `bullet`
-* `choropleth`
-* `bubble-map`
-* `gl-map`
-* `heatmap`
* `radar`
-* `waterfall`
-* `box-plot`
-* `violin`
-* `candlestick`
-* `stock`
* `table`
* `pivot`
diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx
index ed65692d8..60168203c 100644
--- a/content/docs/references/ui/dashboard.mdx
+++ b/content/docs/references/ui/dashboard.mdx
@@ -93,7 +93,7 @@ Dashboard header action
| **id** | `string` | ✅ | Unique widget identifier (snake_case) |
| **title** | `string` | optional | Widget title |
| **description** | `string` | optional | Widget description text below the header |
-| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'grouped-bar' \| 'stacked-bar' \| 'bi-polar-bar' \| 'line' \| 'area' \| 'stacked-area' \| 'step-line' \| 'spline' \| 'pie' \| 'donut' \| 'funnel' \| 'pyramid' \| 'scatter' \| 'bubble' \| 'treemap' \| 'sunburst' \| 'sankey' \| 'word-cloud' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'choropleth' \| 'bubble-map' \| 'gl-map' \| 'heatmap' \| 'radar' \| 'waterfall' \| 'box-plot' \| 'violin' \| 'candlestick' \| 'stock' \| 'table' \| 'pivot'>` | ✅ | Visualization type |
+| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'grouped-bar' \| 'stacked-bar' \| 'bi-polar-bar' \| 'line' \| 'area' \| 'stacked-area' \| 'step-line' \| 'spline' \| 'pie' \| 'donut' \| 'funnel' \| 'pyramid' \| 'scatter' \| 'bubble' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>` | ✅ | Visualization type |
| **chartConfig** | `Object` | optional | Chart visualization configuration |
| **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Widget color variant for theming |
| **requiresObject** | `string` | optional | Hide the widget unless the named object is registered |
diff --git a/content/docs/references/ui/report.mdx b/content/docs/references/ui/report.mdx
index 503e65d8a..0c0a41083 100644
--- a/content/docs/references/ui/report.mdx
+++ b/content/docs/references/ui/report.mdx
@@ -80,7 +80,7 @@ const result = JoinedReportBlock.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'grouped-bar' \| 'stacked-bar' \| 'bi-polar-bar' \| 'line' \| 'area' \| 'stacked-area' \| 'step-line' \| 'spline' \| 'pie' \| 'donut' \| 'funnel' \| 'pyramid' \| 'scatter' \| 'bubble' \| 'treemap' \| 'sunburst' \| 'sankey' \| 'word-cloud' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'choropleth' \| 'bubble-map' \| 'gl-map' \| 'heatmap' \| 'radar' \| 'waterfall' \| 'box-plot' \| 'violin' \| 'candlestick' \| 'stock' \| 'table' \| 'pivot'>` | ✅ | |
+| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'grouped-bar' \| 'stacked-bar' \| 'bi-polar-bar' \| 'line' \| 'area' \| 'stacked-area' \| 'step-line' \| 'spline' \| 'pie' \| 'donut' \| 'funnel' \| 'pyramid' \| 'scatter' \| 'bubble' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'>` | ✅ | |
| **title** | `string` | optional | Chart title |
| **subtitle** | `string` | optional | Chart subtitle |
| **description** | `string` | optional | Accessibility description |
diff --git a/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts b/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts
index bd113ebe5..9a71c5375 100644
--- a/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts
+++ b/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import type { SolutionBlueprint } from '@objectstack/spec/ai';
+import { SolutionBlueprintStrictSchema, type SolutionBlueprint } from '@objectstack/spec/ai';
import { ToolRegistry } from '../tools/tool-registry.js';
import {
registerBlueprintTools,
@@ -287,3 +287,69 @@ describe('apply_blueprint handler', () => {
]);
});
});
+
+// ═══════════════════════════════════════════════════════════════════
+// OpenAI strict structured outputs (live-verified bug: optional fields made
+// OpenAI reject the schema; the model emits null for "empty" fields)
+// ═══════════════════════════════════════════════════════════════════
+
+describe('blueprint ⨯ OpenAI strict structured outputs', () => {
+ // A blueprint shaped like the strict mirror's output: every optional field
+ // present as `null` rather than absent.
+ const bpWithNulls: any = {
+ summary: 's',
+ assumptions: [],
+ questions: null,
+ objects: [
+ {
+ name: 'project',
+ label: 'Project',
+ description: null,
+ fields: [
+ { name: 'name', label: null, type: 'text', required: null, reference: null, options: null },
+ ],
+ },
+ ],
+ views: null,
+ dashboards: null,
+ app: null,
+ };
+
+ it('propose_blueprint uses the strict mirror schema and strips the model\'s nulls', async () => {
+ const registry = new ToolRegistry();
+ const generateObject = vi.fn(async () => ({ object: bpWithNulls, model: 'mock', usage: undefined }));
+ registerBlueprintTools(registry, {
+ ai: { generateObject } as any,
+ protocol: createMockProtocol().protocol,
+ metadataService: createMockMetadataService(),
+ });
+
+ const parsed = parse(await registry.execute(call('propose_blueprint', { goal: 'x' })));
+
+ // The OpenAI-strict mirror is the output contract sent to generateObject.
+ expect((generateObject.mock.calls[0] as unknown[])[1]).toBe(SolutionBlueprintStrictSchema);
+ // Nulls are stripped so the result conforms to the lenient schema.
+ expect(parsed.status).toBe('blueprint_proposed');
+ expect(parsed.blueprint.objects[0].description).toBeUndefined();
+ expect(parsed.blueprint.objects[0].fields[0].label).toBeUndefined();
+ expect(parsed.blueprint.views).toBeUndefined();
+ expect(parsed.blueprint.app).toBeUndefined();
+ });
+
+ it('apply_blueprint tolerates a blueprint carrying nulls (strips before validating)', async () => {
+ const registry = new ToolRegistry();
+ const proto = createMockProtocol();
+ registerBlueprintTools(registry, {
+ ai: createMockAi().ai,
+ protocol: proto.protocol,
+ metadataService: createMockMetadataService(),
+ });
+
+ const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: bpWithNulls })));
+ expect(parsed.status).toBe('drafted');
+ expect(parsed.drafted).toEqual([{ type: 'object', name: 'project' }]);
+ // null field props were stripped, not persisted as null
+ const project = proto.drafts.get('object:project') as any;
+ expect(project.fields.name).toEqual({ type: 'text' });
+ });
+});
diff --git a/packages/services/service-ai/src/__tests__/objectql-conversation-service.test.ts b/packages/services/service-ai/src/__tests__/objectql-conversation-service.test.ts
index 4e0b88d7a..693dd4b5c 100644
--- a/packages/services/service-ai/src/__tests__/objectql-conversation-service.test.ts
+++ b/packages/services/service-ai/src/__tests__/objectql-conversation-service.test.ts
@@ -294,6 +294,28 @@ describe('ObjectQLConversationService', () => {
}
});
+ it('persists a tool-only assistant turn with a non-empty content placeholder (ADR-0033 — `content` is required, so empty text from a tool-only turn must not be stored)', async () => {
+ const conv = await service.create();
+ const insertSpy = vi.spyOn(engine, 'insert');
+ const msg: ModelMessage = {
+ role: 'assistant' as const,
+ content: [
+ { type: 'tool-call' as const, toolCallId: 'c1', toolName: 'propose_blueprint', input: {} },
+ ],
+ };
+ // Previously the empty text content failed the required `content` field,
+ // dropped the turn, and the agent lost context (re-proposed instead of
+ // applying). It must persist with a tool-name placeholder.
+ await expect(service.addMessage(conv.id, msg)).resolves.toBeDefined();
+ const row = insertSpy.mock.calls
+ .map((c) => c[1] as Record)
+ .find((d) => d.role === 'assistant' && 'tool_calls' in d && d.tool_calls);
+ expect(row).toBeTruthy();
+ expect(typeof row!.content).toBe('string');
+ expect((row!.content as string).length).toBeGreaterThan(0);
+ expect(row!.content as string).toContain('propose_blueprint');
+ });
+
it('should throw when adding message to non-existent conversation', async () => {
const msg: ModelMessage = { role: 'user', content: 'Hello' };
await expect(service.addMessage('conv_ghost', msg)).rejects.toThrow(
diff --git a/packages/services/service-ai/src/conversation/objectql-conversation-service.ts b/packages/services/service-ai/src/conversation/objectql-conversation-service.ts
index cfc6ae2f6..01e2c6c81 100644
--- a/packages/services/service-ai/src/conversation/objectql-conversation-service.ts
+++ b/packages/services/service-ai/src/conversation/objectql-conversation-service.ts
@@ -190,7 +190,22 @@ export class ObjectQLConversationService implements IAIConversationService {
const textParts = parts.filter((p): p is { type: 'text'; text: string } => p.type === 'text').map(p => p.text);
const toolCalls = parts.filter(p => p.type === 'tool-call');
contentStr = textParts.join('');
- if (toolCalls.length > 0) toolCallsJson = JSON.stringify(toolCalls);
+ if (toolCalls.length > 0) {
+ toolCallsJson = JSON.stringify(toolCalls);
+ // A tool-only assistant turn carries no text, but `content` is a
+ // required field. Persist a readable placeholder synthesized from the
+ // tool names so the row is valid AND the NEXT turn's rebuilt context
+ // still records that these tools ran — without it the insert fails,
+ // the turn is dropped, and the agent loses the thread (e.g. re-runs
+ // propose_blueprint instead of apply_blueprint). ADR-0033 live-verify.
+ if (!contentStr) {
+ const names = toolCalls
+ .map(tc => (tc as { toolName?: string }).toolName)
+ .filter((n): n is string => !!n)
+ .join(', ');
+ contentStr = names ? `(called ${names})` : '(tool call)';
+ }
+ }
}
} else if (message.role === 'tool') {
contentStr = JSON.stringify(message.content);
@@ -208,7 +223,10 @@ export class ObjectQLConversationService implements IAIConversationService {
id: msgId,
conversation_id: conversationId,
role: message.role,
- content: contentStr,
+ // `content` is required — never persist an empty string (defensive net
+ // for any role that produced no text; the assistant tool-only case above
+ // already substitutes a tool-name placeholder).
+ content: contentStr && contentStr.length > 0 ? contentStr : '(no content)',
tool_calls: toolCallsJson,
tool_call_id: toolCallId,
model: extras?.model ?? null,
diff --git a/packages/services/service-ai/src/tools/blueprint-tools.ts b/packages/services/service-ai/src/tools/blueprint-tools.ts
index 9debbfcc0..2e13cd26d 100644
--- a/packages/services/service-ai/src/tools/blueprint-tools.ts
+++ b/packages/services/service-ai/src/tools/blueprint-tools.ts
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import type { IAIService, IMetadataService, ModelMessage } from '@objectstack/spec/contracts';
-import { SolutionBlueprintSchema, type SolutionBlueprint } from '@objectstack/spec/ai';
+import { SolutionBlueprintSchema, SolutionBlueprintStrictSchema, type SolutionBlueprint } from '@objectstack/spec/ai';
import { stageDraft, type DraftCapableProtocol } from './metadata-tools.js';
import type { ToolHandler, ToolRegistry } from './tool-registry.js';
import { proposeBlueprintTool } from './propose-blueprint.tool.js';
@@ -10,6 +10,28 @@ import { applyBlueprintTool } from './apply-blueprint.tool.js';
export { proposeBlueprintTool } from './propose-blueprint.tool.js';
export { applyBlueprintTool } from './apply-blueprint.tool.js';
+/**
+ * Recursively drop object keys whose value is `null`. The OpenAI-strict output
+ * contract ({@link SolutionBlueprintStrictSchema}) requires every key present
+ * and emits `null` for "empty" optional fields; stripping those nulls makes the
+ * result conform to the lenient {@link SolutionBlueprintSchema} (which uses
+ * `.optional()` — absent, not null) so every downstream consumer is unchanged.
+ */
+function stripNulls(value: T): T {
+ if (Array.isArray(value)) {
+ return value.map((v) => stripNulls(v)) as unknown as T;
+ }
+ if (value && typeof value === 'object') {
+ const out: Record = {};
+ for (const [k, v] of Object.entries(value as Record)) {
+ if (v === null) continue;
+ out[k] = stripNulls(v);
+ }
+ return out as T;
+ }
+ return value;
+}
+
/** All blueprint (plan-first) tool definitions. */
export const BLUEPRINT_TOOL_DEFINITIONS = [proposeBlueprintTool, applyBlueprintTool];
@@ -112,12 +134,16 @@ function createProposeBlueprintHandler(ctx: BlueprintToolContext): ToolHandler {
let blueprint: SolutionBlueprint;
try {
- const generated = await ctx.ai.generateObject(messages, SolutionBlueprintSchema, {
+ // Use the OpenAI-strict-compatible mirror as the output contract (the
+ // lenient SolutionBlueprintSchema's optional fields make OpenAI strict
+ // structured outputs reject the schema). Strip the nulls it emits so the
+ // result conforms to the lenient schema everything else consumes.
+ const generated = await ctx.ai.generateObject(messages, SolutionBlueprintStrictSchema, {
schemaName: 'SolutionBlueprint',
schemaDescription:
- 'A proposed solution: objects + fields + relationships + views + dashboards + an app (navigation shell) + seed data, with stated assumptions.',
+ 'A proposed solution: objects + fields + relationships + views + dashboards + an app (navigation shell), with stated assumptions. Use null for fields that do not apply.',
});
- blueprint = generated.object;
+ blueprint = stripNulls(generated.object) as SolutionBlueprint;
} catch (err) {
return JSON.stringify({
error: `Failed to design blueprint: ${err instanceof Error ? err.message : String(err)}`,
@@ -254,7 +280,10 @@ function createApplyBlueprintHandler(ctx: BlueprintToolContext): ToolHandler {
// Defensive: the model re-emits the (possibly edited) blueprint — validate
// it before fanning out so a malformed plan fails fast with fixable issues.
- const parsed = SolutionBlueprintSchema.safeParse(raw);
+ // Strip any nulls first: the strict output contract emits `null` for empty
+ // optional fields, and the model may carry those through to this call; the
+ // lenient schema expects them absent.
+ const parsed = SolutionBlueprintSchema.safeParse(stripNulls(raw));
if (!parsed.success) {
return JSON.stringify({
error: 'Blueprint failed validation — fix and resend.',
diff --git a/packages/spec/src/ai/solution-blueprint.test.ts b/packages/spec/src/ai/solution-blueprint.test.ts
index 409d36348..63ea4e29b 100644
--- a/packages/spec/src/ai/solution-blueprint.test.ts
+++ b/packages/spec/src/ai/solution-blueprint.test.ts
@@ -3,6 +3,7 @@
import { describe, it, expect } from 'vitest';
import {
SolutionBlueprintSchema,
+ SolutionBlueprintStrictSchema,
defineSolutionBlueprint,
type SolutionBlueprint,
} from './solution-blueprint.zod';
@@ -136,3 +137,55 @@ describe('SolutionBlueprintSchema', () => {
).toThrow();
});
});
+
+// The strict mirror is what `generateObject` sends to OpenAI: every property
+// must be present in `required` (optional → nullable), and no open `z.record`
+// (seedData dropped). A live run proved the lenient schema's optional fields
+// made OpenAI strict structured outputs reject the request.
+describe('SolutionBlueprintStrictSchema (OpenAI strict mirror)', () => {
+ const strictBp = {
+ summary: 's',
+ assumptions: [],
+ questions: null,
+ objects: [
+ {
+ name: 'project',
+ label: null,
+ description: null,
+ fields: [
+ { name: 'name', label: null, type: 'text', required: null, reference: null, options: null },
+ ],
+ },
+ ],
+ views: null,
+ dashboards: null,
+ app: null,
+ };
+
+ it('accepts a blueprint with null for every optional field', () => {
+ const parsed = SolutionBlueprintStrictSchema.parse(strictBp);
+ expect(parsed.objects[0].fields[0].type).toBe('text');
+ expect(parsed.views).toBeNull();
+ expect(parsed.app).toBeNull();
+ });
+
+ it('requires every top-level key to be present (OpenAI strict needs all in `required`)', () => {
+ const { views: _v, ...missingViews } = strictBp;
+ expect(() => SolutionBlueprintStrictSchema.parse(missingViews)).toThrow();
+ });
+
+ it('requires every (nullable) field key to be present — omitting `label` throws', () => {
+ const badField = {
+ ...strictBp,
+ objects: [
+ { name: 'x', label: null, description: null, fields: [{ name: 'f', type: 'text', required: null, reference: null, options: null }] },
+ ],
+ };
+ // `f` is missing the (nullable, required) `label` key.
+ expect(() => SolutionBlueprintStrictSchema.parse(badField)).toThrow();
+ });
+
+ it('drops the un-strict-able seedData record (OpenAI strict cannot represent open key/value maps)', () => {
+ expect('seedData' in SolutionBlueprintStrictSchema.shape).toBe(false);
+ });
+});
diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts
index 118214c22..e667e94ad 100644
--- a/packages/spec/src/ai/solution-blueprint.zod.ts
+++ b/packages/spec/src/ai/solution-blueprint.zod.ts
@@ -141,3 +141,91 @@ export type SolutionBlueprint = z.infer;
export function defineSolutionBlueprint(config: z.input): SolutionBlueprint {
return SolutionBlueprintSchema.parse(config);
}
+
+// ---------------------------------------------------------------------------
+// Strict structured-output mirror (OpenAI / Vercel AI Gateway)
+//
+// OpenAI's *strict* structured outputs (what `generateObject` uses through the
+// gateway) require that EVERY property is listed in `required` and reject
+// open-ended `additionalProperties` (i.e. `z.record`). The authoring schema
+// above is deliberately lenient (optional fields, a free-form `seedData`
+// record), which OpenAI rejects with:
+// "'required' … must include every key in properties. Missing 'label'."
+//
+// This mirror expresses the SAME shape in a strict-compatible way — every key
+// present, "optional" → `.nullable()`, and the un-representable `seedData`
+// record dropped (Phase C only *reports* seed data; it never applies it, and
+// the agent can still describe it in prose). It is used ONLY as the
+// `generateObject` output contract. The model emits `null` for empty fields;
+// the blueprint tools strip those nulls so the lenient {@link
+// SolutionBlueprintSchema} (and every existing consumer/test) is unchanged.
+// ---------------------------------------------------------------------------
+
+const StrictField = z.object({
+ name: z.string().describe('Field machine name (snake_case)'),
+ label: z.string().nullable().describe('Human-readable field label, or null'),
+ type: FieldType.describe('Field data type'),
+ required: z.boolean().nullable().describe('Whether the field is required, or null'),
+ reference: z.string().nullable().describe('Target object for lookup/master_detail, or null'),
+ options: z.array(z.object({ label: z.string(), value: z.string() })).nullable()
+ .describe('Choices for select-family fields, or null'),
+});
+
+const StrictObject = z.object({
+ name: z.string().describe('Object machine name (snake_case)'),
+ label: z.string().nullable().describe('Human-readable singular label, or null'),
+ description: z.string().nullable().describe('What this object represents, or null'),
+ fields: z.array(StrictField).describe('Fields to create on the object'),
+});
+
+const StrictView = z.object({
+ object: z.string().describe('Object this view displays (snake_case)'),
+ name: z.string().describe('View machine name (snake_case)'),
+ label: z.string().nullable().describe('Human-readable view label, or null'),
+ type: z.enum(['list', 'form', 'kanban', 'calendar']).nullable().describe('View kind, or null for list'),
+ columns: z.array(z.string()).nullable().describe('Field names shown as columns, or null'),
+});
+
+const StrictDashboard = z.object({
+ name: z.string().describe('Dashboard machine name (snake_case)'),
+ label: z.string().nullable().describe('Human-readable dashboard label, or null'),
+ widgets: z.array(z.object({
+ id: z.string().describe('Widget id (snake_case)'),
+ title: z.string().nullable().describe('Widget title, or null'),
+ object: z.string().nullable().describe('Source object, or null'),
+ chart: z.enum(['metric', 'bar', 'line', 'pie', 'table']).nullable().describe('Visualization, or null'),
+ })).nullable().describe('Widgets to place on the dashboard, or null'),
+});
+
+const StrictNavItem = z.object({
+ type: z.enum(['object', 'dashboard']).describe('What this nav entry opens'),
+ target: z.string().describe('Object or dashboard machine name to surface (snake_case)'),
+ label: z.string().nullable().describe('Nav entry label, or null'),
+ icon: z.string().nullable().describe('Lucide icon name, or null'),
+});
+
+const StrictApp = z.object({
+ name: z.string().describe('App machine name (snake_case)'),
+ label: z.string().nullable().describe('App display label, or null'),
+ icon: z.string().nullable().describe('Lucide icon for the App Launcher, or null'),
+ nav: z.array(StrictNavItem).nullable()
+ .describe('Navigation entries; null to auto-surface every created object and dashboard'),
+});
+
+/**
+ * OpenAI-strict-compatible mirror of {@link SolutionBlueprintSchema}, used only
+ * as the `generateObject` output contract (see comment above). Validate / apply
+ * still go through the lenient `SolutionBlueprintSchema`.
+ */
+export const SolutionBlueprintStrictSchema = z.object({
+ summary: z.string().describe('One-line description of the proposed solution'),
+ assumptions: z.array(z.string()).describe('Design assumptions made from the underspecified goal'),
+ questions: z.array(z.string()).nullable()
+ .describe('At most 1-2 structure-deciding questions to confirm before building, or null'),
+ objects: z.array(StrictObject).describe('Objects (tables) to create'),
+ views: z.array(StrictView).nullable().describe('Views to create, or null'),
+ dashboards: z.array(StrictDashboard).nullable().describe('Dashboards to create, or null'),
+ app: StrictApp.nullable()
+ .describe('The navigation shell (app) that surfaces the created objects/dashboards, or null'),
+});
+export type SolutionBlueprintStrict = z.infer;