From d529e2d66fd75f0c814ac580afda2343b9aa2d58 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:19:20 +0800 Subject: [PATCH] fix(ai): make ADR-0033 blueprint authoring work with OpenAI structured outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs found via a live Studio e2e run against a real model (OpenAI via the Vercel AI Gateway), both missed by unit tests: 1. propose_blueprint failed under OpenAI strict structured outputs. SolutionBlueprintSchema uses optional fields + a z.record seedData; strict mode requires every property in `required` and rejects open additionalProperties. Add SolutionBlueprintStrictSchema (optional→nullable, no z.record) as the generateObject output contract only; keep the lenient schema for validation; strip the nulls the strict contract emits. 2. Tool-only assistant turns failed to persist (ai_messages.content required), dropping the turn and losing context so the agent re-proposed instead of applying. addMessage now stores a tool-name placeholder + a defensive non-empty fallback. Adds unit tests for both. Verified end-to-end in Studio: propose → confirm → batch-draft objects/views/dashboards/app → review/diff → publish. Co-Authored-By: Claude Opus 4.8 --- .../adr-0033-blueprint-openai-strict.md | 14 ++ content/docs/references/ai/index.mdx | 1 + content/docs/references/ai/meta.json | 1 + .../docs/references/ai/solution-blueprint.mdx | 161 ++++++++++++++++++ content/docs/references/api/discovery.mdx | 1 + content/docs/references/api/metadata.mdx | 4 +- content/docs/references/api/package-api.mdx | 102 +---------- .../docs/references/api/package-registry.mdx | 90 +--------- content/docs/references/api/protocol.mdx | 60 +------ content/docs/references/automation/flow.mdx | 2 + content/docs/references/automation/job.mdx | 38 +++++ content/docs/references/automation/meta.json | 1 + content/docs/references/cloud/marketplace.mdx | 1 + content/docs/references/data/validation.mdx | 102 +++++------ content/docs/references/kernel/index.mdx | 1 - content/docs/references/kernel/manifest.mdx | 135 ++++++++++----- .../references/kernel/metadata-plugin.mdx | 6 +- .../references/kernel/package-registry.mdx | 136 ++------------- .../references/kernel/package-upgrade.mdx | 45 +---- content/docs/references/shared/index.mdx | 5 - content/docs/references/ui/chart.mdx | 15 +- content/docs/references/ui/dashboard.mdx | 2 +- content/docs/references/ui/report.mdx | 2 +- .../src/__tests__/blueprint-tools.test.ts | 68 +++++++- .../objectql-conversation-service.test.ts | 22 +++ .../objectql-conversation-service.ts | 22 ++- .../service-ai/src/tools/blueprint-tools.ts | 39 ++++- .../spec/src/ai/solution-blueprint.test.ts | 53 ++++++ .../spec/src/ai/solution-blueprint.zod.ts | 88 ++++++++++ 29 files changed, 667 insertions(+), 550 deletions(-) create mode 100644 .changeset/adr-0033-blueprint-openai-strict.md create mode 100644 content/docs/references/ai/solution-blueprint.mdx create mode 100644 content/docs/references/automation/job.mdx 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;