From 726b7f23ec8c2ff52db75f86ffe7195938b2eb68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 16:08:23 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20implementation-accuracy=20pass=20?= =?UTF-8?q?=E2=80=94=20retire=20titleFormat=20example=20+=2040=20evidence-?= =?UTF-8?q?backed=20doc=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recheck of the hand-written docs against the actual implementation. Two parts: 1. Deprecated-key sweep (all 146 hand-written docs). The concrete report was `titleFormat` still shown as current usage though it is deprecated ([DEPRECATED → nameField (ADR-0079)] in packages/spec/src/data/object.zod.ts). The live example in data-modeling/formulas.mdx now uses the sanctioned migration — a text `Field.formula` designated via `nameField` — instead of `titleFormat`. Verified the rest of the deprecated authorable-key class is already correct: reference-table rows for titleFormat/displayNameField/ compactLayout/conditionalRequired/defaultExpanded are marked deprecated with their replacements; view form-section `collapsible`/`collapsed` are the current schema (view.zod.ts FormSectionSchema), not the retired object fieldGroup pair; every `namespace:` hit is `manifest.namespace` (a current required field), not the removed ObjectSchema key. 2. Per-doc accuracy fixes across 40 further docs (client SDK, REST/wire format, automation, seed data, deployment, concepts, …). Highlights: client.connect() resolves with the discovery manifest (not client.discovery); auth login takes email; workflow namespace exposes 3 methods; webhooks docs no longer advertise signed-by-default or SSRF egress guards that are not shipped yet; seed `replace` mode inserts without deleting. Every change is backed by the cited implementation; frontmatter, code fences, and JSX components verified intact. Scoped pass: covers the reported deprecated-key issue across the whole corpus plus deep fixes on the docs reached this round; a full per-doc deep audit of the remaining files is follow-up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LHQscZJ8NvXceSmsPNdTZd --- content/docs/ai/actions-as-tools.mdx | 10 +- content/docs/ai/chatbot-integration.mdx | 4 +- content/docs/ai/skills-reference.mdx | 6 +- content/docs/ai/skills.mdx | 14 +- content/docs/api/client-sdk.mdx | 49 ++++--- content/docs/api/data-api.mdx | 18 ++- content/docs/api/data-flow.mdx | 20 +-- content/docs/api/environment-routing.mdx | 23 ++-- content/docs/api/error-catalog.mdx | 13 ++ content/docs/api/error-handling-client.mdx | 2 +- content/docs/api/error-handling-server.mdx | 2 +- content/docs/api/index.mdx | 47 +++---- content/docs/api/metadata-api.mdx | 5 +- content/docs/api/plugin-endpoints.mdx | 27 ++-- content/docs/api/wire-format.mdx | 37 +++--- content/docs/automation/approvals.mdx | 19 ++- content/docs/automation/flows.mdx | 10 +- content/docs/automation/hook-bodies.mdx | 21 +-- content/docs/automation/hooks.mdx | 3 +- content/docs/automation/webhooks.mdx | 121 +++++++++++------- content/docs/automation/workflows.mdx | 2 +- content/docs/concepts/architecture.mdx | 4 +- content/docs/concepts/index.mdx | 6 +- content/docs/concepts/metadata-driven.mdx | 4 +- content/docs/concepts/metadata-lifecycle.mdx | 22 ++-- content/docs/concepts/north-star.mdx | 2 +- content/docs/data-modeling/drivers.mdx | 2 +- .../field-type-decision-tree.mdx | 2 +- content/docs/data-modeling/field-types.mdx | 1 - content/docs/data-modeling/formulas.mdx | 21 ++- content/docs/data-modeling/queries.mdx | 58 +++++++++ content/docs/data-modeling/relationships.mdx | 14 +- content/docs/data-modeling/schema-design.mdx | 12 +- content/docs/data-modeling/seed-data.mdx | 81 +++++++----- .../docs/data-modeling/validation-rules.mdx | 33 ++--- .../docs/deployment/cloud-artifact-api.mdx | 2 +- .../docs/deployment/environment-variables.mdx | 15 +-- content/docs/deployment/index.mdx | 5 +- .../docs/deployment/single-project-mode.mdx | 4 +- content/docs/deployment/troubleshooting.mdx | 8 +- content/docs/deployment/vercel.mdx | 7 +- 41 files changed, 459 insertions(+), 297 deletions(-) diff --git a/content/docs/ai/actions-as-tools.mdx b/content/docs/ai/actions-as-tools.mdx index 40bd3ff0af..f440cd0d98 100644 --- a/content/docs/ai/actions-as-tools.mdx +++ b/content/docs/ai/actions-as-tools.mdx @@ -41,7 +41,7 @@ Three action types are supported: | `action.type` | Dispatch path | Wiring needed | |:---|:---|:---| | `script` | `IDataEngine.executeAction(object, target, ctx)` — the same call Studio makes | none beyond the metadata service | -| `api` | HTTP call to `action.target` via `ApiActionClient` (default: `fetch`) | `apiBaseUrl` (or a custom `apiClient`) | +| `api` | HTTP call to `action.target` via the configured `apiClient` (default: `fetch`) | `apiActionBaseUrl` (or a custom `apiClient`) | | `flow` | `IAutomationService.execute(target, { triggerData })` | `automation` service registered | Console-only types (`url`, `modal`, `form`) are always skipped. Dangerous @@ -158,7 +158,7 @@ automatically scope what an agent can see and do. There is no separate **How it works:** 1. The REST routes for `/api/v1/ai/assistant/chat` and - `/api/v1/ai/agents/:id/chat` pull the authenticated principal out of + `/api/v1/ai/agents/:agentName/chat` pull the authenticated principal out of `req.user` and forward it to `aiService.chatWithTools(...)` as `toolExecutionContext: { actor, conversationId, environmentId }`. Both **cookie session** (`better-auth.session_token`) and **Bearer @@ -197,8 +197,10 @@ Omit `toolExecutionContext` to keep the previous system-level behaviour ## LLM-generated conversation titles -Auto-titling is **opt-in** (disabled by default; enable it via the `ai` -settings namespace from Console → Settings → AI). Once enabled, after a +Auto-titling is **on by default** for any configured LLM provider (the +memory/echo provider never triggers it, since no real LLM call is made); +toggle `title_generation_enabled` off via the `ai` settings namespace from +Console → Settings → AI if you'd rather leave conversations unnamed. Once a conversation has at least one user + assistant exchange (≥ 2 messages) and still has no title, the AI service fires a short, out-of-band LLM call (default cap **16 characters**, single-line, no quotes) to summarise what the diff --git a/content/docs/ai/chatbot-integration.mdx b/content/docs/ai/chatbot-integration.mdx index f863efe123..05ee3433a6 100644 --- a/content/docs/ai/chatbot-integration.mdx +++ b/content/docs/ai/chatbot-integration.mdx @@ -30,7 +30,7 @@ contract. On a cloud / EE dev host (where `@objectstack/service-ai` is available — see the callout above), the `default` and `full` plugin tier presets both -include the `ai` capability, so `objectstack dev` boots the AI services +include the `ai` capability, so `os dev` boots the AI services unless you opt out with `--preset minimal`. Provide a Vercel AI Gateway model and key via env vars: @@ -106,7 +106,7 @@ const chat = useObjectChat({ ## 4. HITL (Human-in-the-Loop) flow When the agent picks a dangerous action (e.g. `delete_task`) the tool -handler enqueues an `ai_pending_action` row and the chat returns a +handler enqueues an `ai_pending_actions` row and the chat returns a `pending_approval` tool result. Your Console UI then: 1. Polls `GET /api/v1/ai/pending-actions?status=pending`. diff --git a/content/docs/ai/skills-reference.mdx b/content/docs/ai/skills-reference.mdx index fc134ce29d..928c3e5e10 100644 --- a/content/docs/ai/skills-reference.mdx +++ b/content/docs/ai/skills-reference.mdx @@ -46,7 +46,7 @@ ObjectStack ships **9 domain-specific skills**. Each is self-contained — an AI | # | Skill | Domain | Path | What it covers | | :--- | :--- | :--- | :--- | :--- | | 1 | [Platform](#platform) | `platform` | `skills/objectstack-platform/` | Bootstrap, configure, extend, and operate ObjectStack runtimes. Covers project setup (`defineStack`, drivers, adapters, scaffolding), plugin and service development (PluginContext, DI, kernel hooks like `kernel:ready` and `data:*`), and operations (CLI commands, migrations, deployment, test harnesses via LiteKernel). | -| 2 | [Data](#data) | `data` | `skills/objectstack-data/` | Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seed datasets (`defineDataset()`) that load fixtures and reference data alongside them. | +| 2 | [Data](#data) | `data` | `skills/objectstack-data/` | Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seed definitions (`defineSeed()`) that load fixtures and reference data alongside them. | | 3 | [Query](#query) | `query` | `skills/objectstack-query/` | Construct ObjectQL queries — filters, sorting, pagination, aggregation, joins/expansion, window functions, and full-text search. | | 4 | [UI](#ui) | `ui` | `skills/objectstack-ui/` | Author ObjectStack UI metadata — Views (list/form/kanban/calendar/gantt), Apps (navigation), Pages (structured plus the HTML and React source-authoring tiers, ADR-0080/0081), Dashboards, Reports, Charts, Actions, and package Docs (`src/docs/*.md`). | | 5 | [Automation](#automation) | `automation` | `skills/objectstack-automation/` | Design ObjectStack automation — Flows (visual logic), Workflows (declarative rules), Triggers, Approvals, scheduled jobs, and webhooks. | @@ -75,7 +75,7 @@ Do not use for data schema design (see objectstack-data) or query patterns (see **Domain** `data` · **Path** `skills/objectstack-data/` -Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seed datasets (`defineDataset()`) that load fixtures and reference data alongside them. +Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seed definitions (`defineSeed()`) that load fixtures and reference data alongside them. Use when the user is creating or modifying `*.object.ts` / `*.seed.ts` files, picking field types, modelling relationships, writing `beforeInsert`/`afterUpdate` hooks, configuring per-object access control, or authoring bootstrap / demo data. Use for `visibleWhen` / `readonlyWhen` / `requiredWhen` rules that belong on fields. @@ -147,7 +147,7 @@ Do not use for general LLM prompting questions unrelated to ObjectStack metadata Design the server-side API surface that an ObjectStack runtime exposes — REST/GraphQL endpoints, auth providers, realtime channels, error envelopes, batch/versioning contracts. -Use when the user is adding `*.endpoint.ts`, configuring auth providers, defining custom routes, or extending the REST/GraphQL generator. +Use when the user is adding `ApiEndpoint` entries to the `apis:` array in `defineStack()`, configuring auth providers, defining custom routes, or extending the REST/GraphQL generator. Do not use for: consuming an ObjectStack API from a client (that is just standard HTTP — no skill needed); the auto-generated CRUD endpoints (those follow from objectstack-data); request-side query syntax (see objectstack-query). CEL expressions in route guards or auth predicates: load objectstack-formula alongside. diff --git a/content/docs/ai/skills.mdx b/content/docs/ai/skills.mdx index 942c2882e7..7e1ec554d8 100644 --- a/content/docs/ai/skills.mdx +++ b/content/docs/ai/skills.mdx @@ -67,7 +67,7 @@ Each skill ships `SKILL.md` plus a generated `references/` index, and richer ski | **Overview** | `SKILL.md` | Yes | High-level guide with decision trees and quick-start examples | | **References** | `references/_index.md` | Generated | Pointers into the published `@objectstack/spec` Zod sources (present in every skill except Formula) | | **Rules** | `rules/*.md` | Optional | Detailed implementation rules with ✅ correct / ❌ incorrect code examples (today: Data, Platform, Query) | -| **Evaluations** | `evals/*.md` | Optional | Test cases to validate AI assistant understanding | +| **Evaluations** | `evals/*.md` | Optional | Test cases to validate AI assistant understanding (mostly planned — `objectstack-automation` ships the first worked example; other skills' `evals/` directories are still placeholders) | ### SKILL.md — The Entry Point @@ -144,11 +144,11 @@ Each skill has **clear boundaries** — it knows what it's responsible for and e ▼ ▼ ▼ ┌───────┐ ┌──────┐ ┌────────────┐ │ Query │ │ UI │ │ Automation │ - └───────┘ └──────┘ └─────┬──────┘ - │ │ - ┌────▼────┐ ┌────▼────┐ - │ API │ │ AI │ - └─────────┘ └─────────┘ + └───┬───┘ └──────┘ └─────┬──────┘ + │ │ + ┌────▼────┐ ┌────▼────┐ + │ API │ │ AI │ + └─────────┘ └─────────┘ Formula and i18n are cross-cutting — load them alongside any host skill. ``` @@ -215,7 +215,7 @@ Each skill maps directly to an ObjectStack protocol domain. The schema skill tea ### 4. Testable -The `evals/` directory in each skill allows teams to validate that their AI assistant correctly understands the protocol. This is analogous to test suites for runtime code — but for AI comprehension. +The `evals/` directory in each skill is reserved for test cases that validate an AI assistant correctly understands the protocol — analogous to test suites for runtime code, but for AI comprehension. Most skills' `evals/` directories are still placeholders; `objectstack-automation` ships the first worked example (`evals/approvals/test-revise-loop.md`). --- diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 7f091c683f..7bcd2d2d59 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -35,11 +35,11 @@ const client = new ObjectStackClient({ }); async function main() { - // 1. Connect — fetches discovery manifest - await client.connect(); + // 1. Connect — resolves with the discovery manifest + const discovery = await client.connect(); // 2. Check available services - console.log('Services:', client.discovery?.services); + console.log('Services:', discovery.services); // → { metadata: { enabled: true, status: 'degraded' }, data: { enabled: true, status: 'available' }, auth: { enabled: false, ... } } // 3. Query data @@ -80,17 +80,16 @@ When you call `client.connect()`, the client: 3. Configures all API route paths dynamically ```typescript -await client.connect(); +// connect() resolves with the discovery manifest — capture the return value +const discovery = await client.connect(); -// Discovery result is available -const discovery = client.discovery; console.log(discovery.version); // "1.0.0" console.log(discovery.environment); // "development" // Check if a service is available before using it if (discovery.services?.auth?.enabled) { // Auth plugin is installed — login is available - await client.auth.login({ username: 'admin', password: 'secret' }); + await client.auth.login({ email: 'admin@example.com', password: 'secret' }); } else { console.log(discovery.services?.auth?.message); // → "Install an auth plugin to enable" @@ -105,7 +104,7 @@ if (discovery.services?.auth?.enabled) { ## Protocol Coverage -The `@objectstack/client` SDK aims to implement the ObjectStack API protocol specification. The core namespaces are listed below; the client also exposes additional namespaces (`approvals`, `feed`, `organizations`, `projects`/environments) — see [`index.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/client/src/index.ts) for the full surface: +The `@objectstack/client` SDK aims to implement the ObjectStack API protocol specification. The core namespaces are listed below; the client also exposes additional namespaces (`approvals`, `feed`, `organizations`, `oauth`, `projects`/environments) — see [`index.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/client/src/index.ts) for the full surface: | Namespace | Status | Methods | Purpose | |:----------|:------:|:--------|:--------| @@ -116,7 +115,7 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe | **permissions** | ✅ | 3 | Access control checks | | **packages** | ✅ | 6 | Plugin/package lifecycle management | | **views** | ✅ | 5 | UI view definitions | -| **workflow** | ✅ | 5 | Workflow state transitions | +| **workflow** | ✅ | 3 | Workflow state transitions | | **analytics** | ✅ | 3 | Analytics queries | | **automation** | ✅ | 1 | Automation triggers | | **storage** | ✅ | 2 | File upload & download | @@ -217,7 +216,7 @@ const result = await client.analytics.query({ cube: 'account', measures: ['revenue.sum', 'count'], dimensions: ['industry'], - filters: [{ member: 'status', operator: 'equals', values: ['active'] }], + where: { status: 'active' }, limit: 100, }); @@ -235,10 +234,10 @@ const explained = await client.analytics.explain({ ```typescript const packages = await client.packages.list(); -await client.packages.install({ name: 'plugin-auth', version: '1.0.0' }); -await client.packages.enable('plugin-auth'); -await client.packages.disable('plugin-auth'); -await client.packages.uninstall('plugin-auth'); +await client.packages.install({ id: 'com.objectstack.plugin-auth', version: '1.0.0' }); +await client.packages.enable('com.objectstack.plugin-auth'); +await client.packages.disable('com.objectstack.plugin-auth'); +await client.packages.uninstall('com.objectstack.plugin-auth'); ``` ### Additional Namespaces @@ -248,7 +247,7 @@ The client also provides full implementations for: ```typescript // Auth — User authentication and session management await client.auth.login({ email: 'user@example.com', password: 'pass' }); -await client.auth.register({ email: 'new@example.com', password: 'pass' }); +await client.auth.register({ email: 'new@example.com', password: 'pass', name: 'New User' }); await client.auth.me(); await client.auth.logout(); await client.auth.refreshToken('refresh-token-string'); @@ -272,10 +271,10 @@ await client.approvals.reject(requestId, { comment: 'Incomplete' }); await client.approvals.listActions(requestId); // audit trail // Realtime — WebSocket subscriptions -await client.realtime.connect({ protocol: 'websocket' }); -await client.realtime.subscribe({ channel: 'account', event: 'update' }); +await client.realtime.connect({ transport: 'websocket' }); +await client.realtime.subscribe({ channel: 'account', events: ['record.updated'] }); await client.realtime.unsubscribe('subscription-id'); -await client.realtime.setPresence('account', { status: 'online' }); +await client.realtime.setPresence('account', { userId: 'user-123', status: 'online', lastSeen: new Date().toISOString() }); await client.realtime.getPresence('account'); await client.realtime.disconnect(); @@ -320,7 +319,7 @@ await client.views.delete('account', viewId); ``` -**Service availability**: Optional services (workflow, ai, etc.) are only available when the corresponding plugin is installed on the server. Always check `client.discovery?.services` to verify service availability before calling these methods. +**Service availability**: Optional services (workflow, ai, etc.) are only available when the corresponding plugin is installed on the server. Always check the `services` map on the discovery result returned by `client.connect()` (cache it yourself — the client has no `discovery` getter) to verify service availability before calling these methods. --- @@ -381,8 +380,8 @@ The `find` method accepts an options object with **canonical** (recommended) fie | `offset` | `number` | Records to skip (OFFSET) | `0` | | `expand` | `Record` or `string[]` | Relation loading (JOIN) | `{ owner: {} }` | - -**Removed in 11:** the legacy query-field aliases were removed — use the canonical names: `fields` (was `select`), `where` (was `filter`/`filters`), `orderBy` (was `sort`), `limit` (was `top`), `offset` (was `skip`). + +**Canonical names recommended:** `find()` accepts the canonical names above; the legacy aliases (`select`, `filter`/`filters`, `sort`, `top`, `skip`) still work for backward compatibility, but new code should use the canonical names. (`@objectstack/client-react`'s `useQuery`/`useInfiniteQuery` hooks are stricter — the legacy aliases were removed there in v11; see `docs/upgrading-to-11.md`.) ### Batch Options @@ -418,9 +417,9 @@ try { |:-----|:-----|:---------|:----------|:------------| | `validation_error` | 400 | validation | No | Input validation failed | | `invalid_query` | 400 | validation | No | Malformed query expression | -| `unauthenticated` | 401 | auth | No | Authentication required | -| `permission_denied` | 403 | auth | No | Insufficient permissions | -| `resource_not_found` | 404 | request | No | Resource does not exist | +| `unauthenticated` | 401 | authentication | No | Authentication required | +| `permission_denied` | 403 | authorization | No | Insufficient permissions | +| `resource_not_found` | 404 | not_found | No | Resource does not exist | | `rate_limit_exceeded` | 429 | rate_limit | Yes | Too many requests | | `internal_error` | 500 | server | Yes | Unexpected server error | | `service_unavailable` | 503 | server | Yes | Service temporarily unavailable | @@ -543,7 +542,7 @@ Integration tests verify end-to-end communication with a live ObjectStack server For detailed information about the client's protocol implementation: -- **[Protocol Compliance Matrix](https://github.com/objectstack-ai/framework/blob/main/packages/client/CLIENT_SPEC_COMPLIANCE.md)** — Method-by-method verification of all API methods across 15 namespaces +- **[Protocol Compliance Matrix](https://github.com/objectstack-ai/framework/blob/main/packages/client/CLIENT_SPEC_COMPLIANCE.md)** — Method-by-method verification of all API methods across 13 namespaces - **[Integration Test Specifications](https://github.com/objectstack-ai/framework/blob/main/packages/client/CLIENT_SERVER_INTEGRATION_TESTS.md)** — Comprehensive test cases for client-server communication - **[Package README](https://github.com/objectstack-ai/framework/blob/main/packages/client/README.md)** — Developer navigation and API reference diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index 381ab792eb..48ac11845f 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -21,12 +21,12 @@ Query records with filtering, sorting, selection, and pagination. | `select` | query | Comma-separated field names | | `filter` | query | Filter expression (JSON). `filters` also accepted for backward compatibility. | | `sort` | query | Sort expression (e.g. `name asc` or `-created_at`) | -| `top` | query | Limit (default: 20) | +| `top` | query | Max records to return. No default — omitting it returns all matching records. | | `skip` | query | Offset | | `expand` | query | Comma-separated list of relations to eager-load | | `search` | query | Full-text search query | -> **Note:** OData-style `$`-prefixed parameters (`$filter`, `$select`, `$orderby`, `$top`, `$skip`) are supported via the [OData endpoint](/docs/references/api/odata). The standard REST API uses unprefixed parameter names. +> **Note:** OData-style `$`-prefixed parameters (`$filter`, `$select`, `$orderby`, `$top`, `$skip`, `$expand`, `$count`, `$search`) are also accepted directly on this same endpoint as aliases — they're normalized internally to the parameter names above. There is no separate standalone OData endpoint. **Response**: ```json @@ -192,13 +192,21 @@ Filtering uses the canonical Query DSL `where` object (the same MongoDB-style `F ### `GET /analytics/meta` -Get auto-generated cube metadata for all objects. +Get metadata for all registered cubes. Cubes are explicitly defined (via `defineCube` +or the analytics service's `cubes` config) — a cube referenced by a query that isn't +yet registered is lazily auto-inferred from that query's shape, but metadata isn't +proactively generated for every object. -**Response**: Array of cube definitions with measures, dimensions, and time dimensions. +**Response**: Array of cube definitions with measures and dimensions (time-based +dimensions are `dimensions` entries with `type: "time"`). ### `POST /analytics/sql` -Execute a raw SQL analytics query (if supported by driver). +Generate the SQL for a given analytics query **without executing it** (dry-run/debug). +Accepts the same body shape as `/analytics/query`; support depends on the underlying +driver/strategy. + +**Response**: `{ success: true, data: { sql: string, params: unknown[] } }` --- diff --git a/content/docs/api/data-flow.mdx b/content/docs/api/data-flow.mdx index 96c6e9ab58..1e6ea50e81 100644 --- a/content/docs/api/data-flow.mdx +++ b/content/docs/api/data-flow.mdx @@ -103,7 +103,7 @@ sequenceDiagram Sec-->>K: Fields filtered by profile K-->>API: Final record - API-->>C: 200 OK { data: { ... } } + API-->>C: 200 OK { object: 'task', id: 'tsk_123', record: { ... } } ``` @@ -129,15 +129,15 @@ sequenceDiagram participant EB as Event Bus participant WS as WebSocket - C->>API: POST /api/v1/data/task { data: {...} } + C->>API: POST /api/v1/data/task { title: '...', ... } API->>K: create('task', data, context) K->>V: validate(schema, data) alt Validation Failed - V-->>K: ZodError - K-->>API: 400 VALIDATION_ERROR - API-->>C: { error: { details: [...] } } + V-->>K: ValidationError { fields: [...] } + K-->>API: 400 VALIDATION_FAILED + API-->>C: { error, code: 'VALIDATION_FAILED', fields: [...] } end V-->>K: ✓ Valid data @@ -165,7 +165,7 @@ sequenceDiagram EB->>WS: Broadcast to subscribers K-->>API: Created record - API-->>C: 201 Created { data: { id: 'tsk_456', ... } } + API-->>C: 201 Created { object: 'task', id: 'tsk_456', record: { ... } } ``` --- @@ -199,7 +199,7 @@ flowchart LR | Output | Used By | Purpose | |:---|:---|:---| -| JSON Schema | VS Code, IntelliJ | Autocomplete and validation in `*.object.ts` files | +| JSON Schema | VS Code, IntelliJ | Autocomplete and validation for `objectstack.config.ts` (via `os generate schema`) | | TypeScript Types | Plugin developers | Type-safe access to object definitions | | Manifest | Kernel | Runtime metadata for query validation and execution | | Metadata API | Client SDK | Dynamic object/field discovery | @@ -211,6 +211,10 @@ flowchart LR How data changes propagate to connected clients in real time. + +**Implementation status:** This diagram (and the `emit(...)` → `EB->>WS` broadcast step in the Write Flow diagram above) shows the target design. The realtime service that ships today is an **in-memory pub/sub adapter** (`InMemoryRealtimeAdapter` in `@objectstack/service-realtime`), delivered to JS clients via **long-polling** (`RealtimeAPI` in `@objectstack/client`) — no WebSocket transport is wired up yet. The adapter also only matches subscriptions by `object` and `eventTypes`; per-field `filter` conditions (e.g. `{ project: 'prj_1' }`) are accepted in the request shape but are not yet enforced server-side. See [Real-Time Protocols](/docs/protocol/objectos/realtime-protocol) for the full implementation-status breakdown. + + ```mermaid sequenceDiagram participant W as Writer Client @@ -246,7 +250,7 @@ sequenceDiagram Sub->>R2: { event: 'task.updated', data: { id: 'tsk_123', ... } } K-->>API: Updated record - API-->>W: 200 OK { data: { ... } } + API-->>W: 200 OK { object: 'task', id: 'tsk_123', record: { ... } } ``` diff --git a/content/docs/api/environment-routing.mdx b/content/docs/api/environment-routing.mdx index 49640173e1..f79fddeab1 100644 --- a/content/docs/api/environment-routing.mdx +++ b/content/docs/api/environment-routing.mdx @@ -27,25 +27,22 @@ Enable scoped route registration in `objectstack.config.ts`: ```typescript import { defineStack } from '@objectstack/spec'; -const stack = defineStack({ +export default defineStack({ // ...your manifest, objects, apis, etc. -}); - -export default { - ...stack, api: { enableProjectScoping: true, projectResolution: 'auto', }, -}; +}); ``` - -`defineStack` validates against a schema that has no top-level `api` field, so -unknown keys are stripped — passing `api` *inside* `defineStack({ ... })` is -silently dropped and scoping stays disabled. Attach the `api` block to the -exported config object instead, as shown above. The CLI reads it from the -exported config (`config.api`) when registering the REST and dispatcher plugins. + +`api` is a declared top-level field on `ObjectStackDefinitionSchema`, so it +survives `defineStack`'s strict parsing — you can pass it directly inside the +`defineStack({ ... })` call as shown above. (Older stacks that instead spread +it onto the exported config object, e.g. `export default { ...stack, api: {...} }`, +still work the same way.) The CLI reads the resolved value from the exported +config (`config.api`) when registering the REST and dispatcher plugins. The option names are historical for compatibility with existing config files; @@ -73,7 +70,7 @@ const client = new ObjectStackClient({ const env = client.project('env_prod'); -await env.data.find('customer', { limit: 20 }); +await env.data.find('customer', { top: 20 }); await env.meta.getItems('object'); await env.packages.list(); ``` diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index 58837f70e4..dba8b340e9 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -12,6 +12,10 @@ ObjectStack uses a structured error system with **9 error categories** and **51 **Import:** `import { StandardErrorCode, ErrorCategory, ErrorResponseSchema } from '@objectstack/spec/api'` + +**Spec vs. wire format:** The codes below are `StandardErrorCode` — the standardized, lowercase snake_case contract that plugin/hook authors should throw with (see [Server-Side Error Handling](/docs/api/error-handling-server)). The kernel REST server (`@objectstack/rest`) that serves `/api/v1/data/*` today emits a flatter envelope with SCREAMING_SNAKE_CASE codes instead — e.g. `VALIDATION_FAILED`, `PERMISSION_DENIED`, `RECORD_NOT_FOUND`, `CONCURRENT_UPDATE` — not the codes in this catalog. See the [API Overview](/docs/api#error-handling) for both wire formats in use and [Wire Format](/docs/api/wire-format#7-error-response-format) for JSON examples. + + --- ## Error Categories @@ -384,6 +388,15 @@ interface FieldError { ## Client-Side Error Handling + +The `@objectstack/client` SDK's built-in fetch error handling attaches only +`code`, `category`, `httpStatus`, `retryable`, and `details` to the thrown +error — not `fieldErrors`, `retryAfter`, or `requestId` directly. If your +server populates those on the response body, read them from +`apiError.details` (e.g. `apiError.details?.fieldErrors`) until the client +surfaces them at the top level. + + ### TypeScript Example ```typescript diff --git a/content/docs/api/error-handling-client.mdx b/content/docs/api/error-handling-client.mdx index 597186f04b..b406d64fa6 100644 --- a/content/docs/api/error-handling-client.mdx +++ b/content/docs/api/error-handling-client.mdx @@ -138,7 +138,7 @@ function TaskForm() { const response = await fetch('/api/v1/data/task', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ data }), + body: JSON.stringify(data), }); if (!response.ok) { diff --git a/content/docs/api/error-handling-server.mdx b/content/docs/api/error-handling-server.mdx index e51de23aa1..df4325096e 100644 --- a/content/docs/api/error-handling-server.mdx +++ b/content/docs/api/error-handling-server.mdx @@ -8,7 +8,7 @@ description: Best practices for handling and throwing errors in ObjectStack plug This guide covers best practices for handling and throwing errors within ObjectStack plugins and server-side code — from creating custom errors with proper codes to transaction rollback and integration with `safeParsePretty()`. -**Error Contract:** ObjectStack defines a standardized error envelope, `ErrorResponseSchema` (exported from `@objectstack/spec/api`). It wraps an `EnhancedApiError` with a machine-readable `code`, `httpStatus`, optional `category`, and `fieldErrors`. Errors you throw on the server should carry these fields so the API layer can serialize them into that envelope. +**Error Contract:** ObjectStack defines a standardized error envelope, `ErrorResponseSchema` (exported from `@objectstack/spec/api`). It wraps an `EnhancedApiError` with a machine-readable `code`, `httpStatus`, optional `category`, and `fieldErrors`. This is the spec's target contract for plugin/hook authors to throw against — but the kernel REST server (`@objectstack/rest`) that serves `/api/v1/data/*` does not auto-translate an arbitrary thrown error into this envelope today. It emits a flatter `{ error, code }` shape and only recognizes a small, fixed set of SCREAMING_SNAKE_CASE codes (`VALIDATION_FAILED`, `PERMISSION_DENIED`, `RECORD_NOT_FOUND`, `CONCURRENT_UPDATE`, `DELETE_RESTRICTED`, …). See the [API Overview](/docs/api#error-handling) and [Wire Format](/docs/api/wire-format#7-error-response-format) for both wire formats in use, and the [Error Catalog](/docs/api/error-catalog) for the full `StandardErrorCode` list. --- diff --git a/content/docs/api/index.mdx b/content/docs/api/index.mdx index 075e2f9d71..7ea6622d36 100644 --- a/content/docs/api/index.mdx +++ b/content/docs/api/index.mdx @@ -15,7 +15,7 @@ ObjectStack exposes a fully typed REST API. All endpoints use JSON request/respo | :--- | :--- | | **REST** | ✅ Auto-generated from the protocol (`@objectstack/rest`) — CRUD, query, batch, metadata, packages | | **Realtime** | ⚠️ In-process pub/sub service (`@objectstack/service-realtime`, single-instance); the `/realtime/*` REST routes and WebSocket/SSE transport are plugin-provided — none ships in the open framework | -| **MCP** | ✅ Objects and opted-in actions exposed as Model Context Protocol tools ([AI module](/docs/ai)) | +| **MCP** | ✅ Non-system objects and their actions exposed as Model Context Protocol tools, gated by the caller's permissions/RLS rather than a per-action opt-in flag ([AI module](/docs/ai)) | | **GraphQL** | ⚠️ Route is wired but **bring-your-own service**: `/graphql` returns 501 unless an implementation of the `IGraphQLService` contract is registered — none ships in the open framework | | **OData** | ⚠️ Vocabulary only: REST list endpoints accept OData-style operators (e.g. `$top`), but there is no standalone OData endpoint | @@ -53,50 +53,37 @@ Returns the full discovery manifest. **Response**: ```json { - "name": "ObjectOS", "version": "1.0.0", - "environment": "development", + "apiName": "ObjectStack API", "routes": { "data": "/api/v1/data", "metadata": "/api/v1/meta", - "analytics": "/api/v1/analytics", - "auth": null, - "workflow": null - }, - "features": { - "graphql": false, - "search": false, - "websockets": false, - "files": false, - "analytics": true, - "ai": false, - "workflow": false, - "notifications": false, - "i18n": false + "analytics": "/api/v1/analytics" }, "services": { "metadata": { "enabled": true, - "status": "degraded", + "status": "available", "route": "/api/v1/meta", - "provider": "kernel", - "message": "In-memory registry; DB persistence pending" + "provider": "objectql" }, - "data": { "enabled": true, "status": "available", "route": "/api/v1/data", "provider": "kernel" }, - "analytics": { "enabled": true, "status": "available", "route": "/api/v1/analytics" }, - "auth": { "enabled": false, "status": "unavailable", "message": "Install an auth plugin to enable" } + "data": { "enabled": true, "status": "available", "route": "/api/v1/data", "provider": "objectql" }, + "analytics": { "enabled": true, "status": "available", "route": "/api/v1/analytics", "provider": "objectql" }, + "auth": { "enabled": false, "status": "unavailable", "message": "Install plugin-auth to enable" } }, - "locale": { - "default": "en", - "supported": ["en", "zh-CN"], - "timezone": "UTC" + "capabilities": { + "feed": { "enabled": false }, + "automation": { "enabled": false }, + "search": { "enabled": false } } } ``` +Disabled/uninstalled route keys (e.g. `auth`, `workflow`) are omitted from `routes` entirely rather than set to `null`; check `services` to tell "not installed" apart from "installed but not yet mounted here." + ### `GET /.well-known/objectstack` -Alias for the discovery endpoint used by auto-discovery in the client SDK. +Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its body is wrapped as `{ "data": { ... } }` and includes fields (`name`, `environment`, `features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does not. The client SDK's `connect()` tries `/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or the bare `body`. **Service Status Values**: `available` (fully operational), `registered` (route declared but handler unverified — may return 501), `degraded` (partial functionality), `unavailable` (not installed), `stub` (placeholder that throws errors) @@ -117,11 +104,11 @@ Error responses depend on which HTTP server is in front of the kernel. There are } ``` -Validation failures additionally include an `issues` array. Common codes emitted by the kernel REST server: +Validation failures additionally include a `fields` array (one entry per invalid field). Common codes emitted by the kernel REST server: | Code | HTTP | Description | |:-----|:-----|:------------| -| `VALIDATION_FAILED` | 400 | Input validation failed (includes `issues`) | +| `VALIDATION_FAILED` | 400 | Input validation failed (includes `fields`) | | `PERMISSION_DENIED` | 403 | Insufficient permissions | | `RECORD_NOT_FOUND` | 404 | Resource does not exist | | `CONCURRENT_UPDATE` | 409 | Record was modified by another user | diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index 3d6dbda821..d36e7d1be5 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -70,9 +70,10 @@ List installed packages. ### `POST /packages` -Install a package from manifest. +Publish a package (manifest + metadata) to the package registry. -**Body**: `{ manifest: { name: "plugin-auth", version: "1.0.0", ... } }` +**Body**: `{ manifest: { id: "plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, metadata: { objects: [...], views: [...], ... } }` +**Response**: `{ success: true, message: "...", package: { id: "plugin-auth", version: "1.0.0" } }` ### `GET /packages/:id` diff --git a/content/docs/api/plugin-endpoints.mdx b/content/docs/api/plugin-endpoints.mdx index 55ee4d12dd..50e8c06094 100644 --- a/content/docs/api/plugin-endpoints.mdx +++ b/content/docs/api/plugin-endpoints.mdx @@ -13,12 +13,12 @@ These REST endpoints are only available when the corresponding plugin is install These endpoints are only available when an auth plugin is installed. Check `discovery.services.auth.enabled` first. -### `POST /auth/login` +### `POST /auth/sign-in/email` -Authenticate and receive a session token. +Authenticate with email and password (better-auth's email sign-in route, mounted under the auth plugin's `/auth` prefix) and receive a session token. There is no `/auth/login` route. -**Body**: `{ username: "admin", password: "secret" }` -**Response**: `{ token: "jwt...", expiresAt: "...", user: { ... } }` +**Body**: `{ email: "admin@example.com", password: "secret" }` +**Response**: `{ token: "jwt...", user: { ... } }` --- @@ -48,11 +48,15 @@ The automation dispatcher also exposes flow CRUD (`GET`/`POST /automation`, `GET | Method | Endpoint | Description | |:-------|:---------|:------------| -| GET | `/ui/views?object=:object` | List views for an object | -| GET | `/ui/views/:viewId` | Get a view definition | -| POST | `/ui/views` | Create a new view | -| PATCH | `/ui/views/:viewId` | Update a view | -| DELETE | `/ui/views/:viewId` | Delete a view | +| GET | `/ui/views/:object` | List views for an object | +| GET | `/ui/views/:object/:viewId` | Get a view definition | +| POST | `/ui/views/:object` | Create a new view | +| PATCH | `/ui/views/:object/:viewId` | Update a view | +| DELETE | `/ui/views/:object/:viewId` | Delete a view | + + +The auto-generated (non-CRUD) view resolver `GET /ui/view/:object/:type` is always available and provided by the kernel — see [Metadata API](/docs/api/metadata-api). It is a separate route from the `/ui/views` CRUD above. + ### Realtime (`/realtime`) — Plugin Required @@ -82,10 +86,13 @@ The core dispatcher implements only the list / read / read-all routes above. Dev | Method | Endpoint | Description | |:-------|:---------|:------------| | POST | `/ai/nlq` | Natural language → query | -| POST | `/ai/chat` | AI chat conversation | | POST | `/ai/suggest` | Get value suggestions | | POST | `/ai/insights` | Get data insights | + +There is no `/ai/chat` route — the AI chat route was removed so the wire protocol aligns with the Vercel AI SDK. Use `useChat()` (`@ai-sdk/react`) directly against the streaming chat endpoint rather than the client SDK's `ai` namespace, which intentionally does not expose a `chat` method. + + ### i18n (`/i18n`) — Plugin Required | Method | Endpoint | Description | diff --git a/content/docs/api/wire-format.mdx b/content/docs/api/wire-format.mdx index 92b17ab6f5..97ee9e64a7 100644 --- a/content/docs/api/wire-format.mdx +++ b/content/docs/api/wire-format.mdx @@ -61,7 +61,7 @@ The response is the `CreateDataResponse` envelope: `{ object, id, record }`. "created_at": "2025-01-20T10:30:00.000Z", "updated_at": "2025-01-20T10:30:00.000Z", "created_by": "usr_01HQ3V5K8N2M4P6R7T9W", - "owner": "usr_01HQ3V5K8N2M4P6R7T9W" + "owner_id": "usr_01HQ3V5K8N2M4P6R7T9W" } } ``` @@ -131,7 +131,7 @@ Filtering uses the MongoDB-style `where` clause: `{ field: value }` for equality }, "fields": ["id", "title", "status", "priority", "assigned_to", "due_date"], "expand": { - "assigned_to": { "object": "user", "fields": ["id", "name"] } + "assigned_to": { "object": "sys_user", "fields": ["id", "name"] } }, "orderBy": [ { "field": "due_date", "order": "asc" } @@ -225,7 +225,7 @@ The response is the `UpdateDataResponse` envelope: `{ object, id, record }`. "created_at": "2025-01-20T10:30:00.000Z", "updated_at": "2025-01-20T14:15:00.000Z", "created_by": "usr_01HQ3V5K8N2M4P6R7T9W", - "owner": "usr_01HQ3V5K8N2M4P6R7T9W" + "owner_id": "usr_01HQ3V5K8N2M4P6R7T9W" } } ``` @@ -302,7 +302,7 @@ Metadata is addressed by type name. `object` is the metadata type, so this route "name": "assigned_to", "label": "Assigned To", "type": "lookup", - "reference": "user" + "reference": "sys_user" }, { "name": "due_date", @@ -365,7 +365,7 @@ Field-level failures carry a `fields` array. ```json { - "error": "Insufficient permissions to update task records", + "error": "[Security] Access denied: operation 'update' on object 'task' is not permitted for roles [standard_user]", "code": "PERMISSION_DENIED", "object": "task" } @@ -394,7 +394,7 @@ Returned when an `If-Match` / `expectedVersion` token no longer matches the stor **`POST /api/v1/data/task/batch`** -Process many records of a **single** operation type in one request. The body carries one `operation` (`create`, `update`, `upsert`, or `delete`) plus a `records` array. By default the batch is atomic (rolled back on any failure); set `options.atomic: false` to allow partial success. +Process many records of a **single** operation type in one request. The body carries one `operation` (`create`, `update`, `upsert`, or `delete`) plus a `records` array. By default (`options.atomic: true`) processing stops at the first failing record — records already written earlier in the same batch are **not** rolled back, since there is no wrapping database transaction. Set `options.atomic: false` (with `options.continueOnError: true`) to keep processing every record and collect a full partial-success report. ### Request @@ -415,7 +415,7 @@ Process many records of a **single** operation type in one request. The body car ### Response — `200 OK` -The response is the `BatchUpdateResponse` envelope: a top-level `success` flag plus `total` / `succeeded` / `failed` counts and a per-record `results` array. +The response is the `BatchUpdateResponse` envelope: a top-level `success` flag plus `total` / `succeeded` / `failed` counts and a per-record `results` array. Each successful entry echoes the written `record`; pass `options.returnRecords: false` to get back just `{ id, success }` per result. ```json { @@ -425,15 +425,15 @@ The response is the `BatchUpdateResponse` envelope: a top-level `success` flag p "succeeded": 2, "failed": 0, "results": [ - { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "success": true, "index": 0 }, - { "id": "tsk_01HQ4B8C0E4G6H9K3L5M", "success": true, "index": 1 } + { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "success": true, "record": { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "status": "done" } }, + { "id": "tsk_01HQ4B8C0E4G6H9K3L5M", "success": true, "record": { "id": "tsk_01HQ4B8C0E4G6H9K3L5M", "status": "done" } } ] } ``` ### Partial Failure Response -When `options.atomic: false` and some records fail, the failing entries carry an `errors` array: +When `options.atomic: false` and some records fail, the failing entries carry a single `error` message string (not an array): ```json { @@ -443,14 +443,8 @@ When `options.atomic: false` and some records fail, the failing entries carry an "succeeded": 1, "failed": 1, "results": [ - { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "success": true, "index": 0 }, - { - "success": false, - "index": 1, - "errors": [ - { "code": "record_not_found", "message": "Record not found: task/tsk_invalid_id" } - ] - } + { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "success": true, "record": { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "status": "done" } }, + { "id": "tsk_invalid_id", "success": false, "error": "Record tsk_invalid_id not found in task" } ] } ``` @@ -479,7 +473,8 @@ When `options.atomic: false` and some records fail, the failing entries carry an | Header | Description | |:---|:---| | `X-Request-Id` | Server-assigned (or echoed) request ID | -| `X-RateLimit-Limit` | Maximum requests per window | -| `X-RateLimit-Remaining` | Remaining requests in current window | -| `X-RateLimit-Reset` | UTC timestamp when the window resets | | `ETag` | Entity tag for conditional requests | + + +**Rate limiting is opt-in:** ObjectStack ships a token-bucket `RateLimiter` primitive (`@objectstack/runtime`), but it is **not** wired into the default REST response path — a deployment must add it at the adapter layer. Only when a deployment does so will responses carry `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset` (and a `429` with `Retry-After` once the limit is hit). + diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index e538ee99ea..0c3435dc23 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -34,11 +34,20 @@ The node declares **who approves** (a named user, a role, the submitter's manage // Illustrative — see the Approval reference for the exact node schema. defineFlow({ name: 'invoice_approval', + type: 'record_change', runAs: 'user', // submitter's RLS unless a step needs more - trigger: { object: 'invoice', on: 'create' }, nodes: [ - { type: 'approval', approver: { type: 'role', role: 'finance_manager' }, - onApprove: 'mark_approved', onReject: 'mark_rejected' }, + { id: 'start', type: 'start', label: 'Start', + config: { objectName: 'invoice', triggerType: 'record-after-create' } }, + { id: 'approval', type: 'approval', label: 'Approval', + config: { approvers: [{ type: 'role', value: 'finance_manager' }] } }, + { id: 'mark_approved', type: 'update_record', label: 'Mark Approved' }, + { id: 'mark_rejected', type: 'update_record', label: 'Mark Rejected' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'approval' }, + { id: 'e2', source: 'approval', target: 'mark_approved', label: 'approve' }, + { id: 'e3', source: 'approval', target: 'mark_rejected', label: 'reject' }, ], }); ``` @@ -74,7 +83,7 @@ export const opportunityApproval: Flow = { type: 'approval', label: 'Manager Approval', config: { - approvers: [{ type: 'user', value: '${record.owner_manager_id}' }], + approvers: [{ type: 'field', value: 'owner_manager_id' }], behavior: 'unanimous', approvalStatusField: 'approval_status', lockRecord: true, @@ -118,7 +127,7 @@ Separating *who configures* from *who approves* from *as whom it runs* is the sa ## Runnable example -- Flows: [`examples/app-showcase/src/flows`](https://github.com/objectstack-ai/framework/tree/main/examples/app-showcase/src/flows). +- Flows: [`examples/app-showcase/src/automation/flows`](https://github.com/objectstack-ai/framework/tree/main/examples/app-showcase/src/automation/flows). - Approval schema: [`packages/spec/src/automation/approval.zod.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/spec/src/automation/approval.zod.ts). ## Anti-patterns diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 667e001d46..6c55f5705c 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -595,9 +595,9 @@ export const hotLeadFollowUp: Flow = { label: 'Create Follow-up Task', config: { object: 'task', - data: { + fields: { subject: 'Follow up on hot lead', - related_to: '${record.id}', + related_to: '{record.id}', priority: 'high', }, }, @@ -607,8 +607,10 @@ export const hotLeadFollowUp: Flow = { type: 'notify', label: 'Notify Owner', config: { - channel: 'in_app', - template: 'hot_lead_created', + recipients: '{record.owner}', + title: 'New hot lead', + message: 'Hot lead created: {record.name}', + channels: ['inbox'], }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/content/docs/automation/hook-bodies.mdx b/content/docs/automation/hook-bodies.mdx index 814043c8ba..ed1312fb9f 100644 --- a/content/docs/automation/hook-bodies.mdx +++ b/content/docs/automation/hook-bodies.mdx @@ -5,7 +5,7 @@ description: How hook handlers and script-action bodies travel through ObjectSta # Hook & Action Bodies -ObjectStack treats every hook handler and every `type: 'script'` action as **pure metadata**. There is no separate `.mjs` file shipped alongside the project artifact, no dynamic `import()` at runtime, and no filesystem dependency on the cloud. A body is either: +ObjectStack treats every hook handler and every `type: 'script'` action as **pure metadata**. In the self-contained (body-only) form there is no separate `.mjs` file shipped alongside the project artifact, no dynamic `import()` at runtime, and no filesystem dependency on the cloud — though today a legacy `objectstack-runtime.{hash}.mjs` back-compat bundle can still ship, and does get dynamically imported at boot, for any handler that hasn't been lowered to a metadata `body` yet (see [Migration](#migration) below). A body is either: - **L1 — Expression**    a formula-engine string, side-effect-free. - **L2 — Sandboxed JS**    a JavaScript source string executed inside an isolated VM with declared capabilities. @@ -33,7 +33,7 @@ export default defineStack({ ``` ```jsonc -// Build artifact (account.hook.json — what objectos actually loads) +// Build artifact (excerpt from dist/objectstack.json's "hooks" array — what objectos actually loads) { "name": "normalize_account", "object": "account", @@ -61,9 +61,8 @@ This is the same trade-off ServiceNow made (Business Rules), Salesforce made (Fo ## L1 — Expression bodies -Pure formula. No IO, no mutation. Used for: +Pure formula. No IO, no mutation. This is the `body.language: 'expression'` shape, used for: -- Hook `condition` (run-this-hook? predicate) - Action `body` for trivial computed values - Validation rules @@ -71,7 +70,9 @@ Pure formula. No IO, no mutation. Used for: { "language": "expression", "source": "input.amount > 1000 && input.status == 'open'" } ``` -Evaluated by the same formula engine that powers field formulas — see [Formula Reference](/docs/data-modeling/formulas). +Hook `condition` is a **separate field with a different envelope** — a bare CEL string, or `{ dialect: 'cel', source }` (`ExpressionInputSchema` in `packages/spec/src/shared/expression.zod.ts`), not a `{ language, source }` body. e.g. `condition: 'record.status == "open" && record.amount > 1000'`. + +Both forms are evaluated by the same formula engine that powers field formulas — see [Formula Reference](/docs/data-modeling/formulas). ## L2 — Sandboxed JS bodies @@ -99,7 +100,7 @@ The script sees only what the surrounding `ctx` object exposes: | `ctx.api.object(name).find\|count\|aggregate` | Cross-object reads, scoped to current tenant. | `api.read` | | `ctx.api.object(name).insert\|update\|delete` | Cross-object writes. | `api.write` | | `ctx.crypto.randomUUID()` | UUID generation. | `crypto.uuid` | -| `ctx.crypto.hash(algo, data)` | Sha-256/512 etc. | `crypto.hash` | +| `ctx.crypto.hash(algo, data)` _(not yet wired)_ | Sha-256/512 etc. The `crypto.hash` capability exists in the schema and is inferred by the build-time extractor, but the QuickJS sandbox currently only installs `ctx.crypto.randomUUID` — calling `ctx.crypto.hash(...)` throws inside the VM today. | `crypto.hash` | | `ctx.log.{info,warn,error}` | Structured logging. | `log` | | `ctx.connector(name).(...)` _(planned)_ | Outbound HTTP / SaaS calls. **Not yet wired into the sandbox** — ships with the separate Connector spec. | (separate Connector spec) | @@ -108,9 +109,9 @@ The script sees only what the surrounding `ctx` object exposes: The CLI builder **rejects** any source that uses: - `import` / `require` / dynamic `import()` -- `fetch`, `XMLHttpRequest`, `WebSocket` -- `process`, `globalThis`, `Buffer`, `setImmediate` -- `eval`, `new Function`, `Function` constructor +- `fetch` +- `process`, `globalThis` +- `eval`, `new Function` - references to identifiers from value-only top-level imports Need outbound HTTP? Define a **Connector recipe** as metadata and call it via `ctx.connector(...)`. (Connector spec is tracked separately and ships after L1+L2 stabilises.) @@ -148,7 +149,7 @@ If you have a body that genuinely cannot be expressed in L1+L2 (typically: it ne 2. For each inline handler, take its source via `String(fn)` (the callable is already loaded by tsx/esbuild). 3. Run a regex allow-list over the stringified body (see "What the sandbox forbids" above). 4. **Pass:** emit `body: { language: 'js', source: , capabilities: }`. -5. **Forbidden token (default):** extraction fails, a `bodyExtractionWarning` is recorded, and the callable still ships via the back-compat handler-ref bundle — the build does **not** abort. Pass `objectstack compile --strict-body` to turn extraction warnings into a hard build failure (exit 1) with a per-callable diagnostic, e.g. `hook 'normalize_account': fetch() is not allowed in hook bodies — declare a Connector recipe instead.` +5. **Forbidden token (default):** extraction fails, a `bodyExtractionWarning` is recorded, and the callable still ships via the back-compat handler-ref bundle — the build does **not** abort. Pass `objectstack compile --strict-body` to turn extraction warnings into a hard build failure (exit 1) with a per-callable diagnostic, e.g. `hook 'normalize_account': fetch() is not allowed in hook/action bodies — declare a Connector recipe instead.` Capabilities are inferred by matching known patterns in the body source (e.g. `ctx.api.object(...).insert(...)` ⇒ `api.write`). A fuller AST-based analysis is planned for a later version. You can override with a directive comment when the inference is wrong. diff --git a/content/docs/automation/hooks.mdx b/content/docs/automation/hooks.mdx index 25b204f933..4c715bf589 100644 --- a/content/docs/automation/hooks.mdx +++ b/content/docs/automation/hooks.mdx @@ -86,7 +86,8 @@ ctx = { result, // mutable operation result (after* events) previous, // record state before the operation (update/delete) session, // { userId, tenantId, roles, accessToken, isSystem } - user, // { id, name, email } convenience shortcut + user, // { id, name, email } convenience shortcut — reserved for future use; + // not currently set by the engine, use session.userId instead transaction, // active transaction handle, if any ql, // ObjectQL engine reference api, // scoped cross-object access: ctx.api.object('x') diff --git a/content/docs/automation/webhooks.mdx b/content/docs/automation/webhooks.mdx index 4b939355b3..b3791c6b1e 100644 --- a/content/docs/automation/webhooks.mdx +++ b/content/docs/automation/webhooks.mdx @@ -44,27 +44,33 @@ rely on a stable contract. ## 2. Design principles **P1 — At-least-once, never at-most-once.** Receivers MUST be prepared -for duplicates. We never silently drop a delivery, but we also never -guarantee uniqueness — that requires receiver-side idempotency keys, which -we provide but cannot enforce. +for duplicates. Once a delivery is enqueued as a `sys_http_delivery` row we +never silently drop it, but we also never guarantee uniqueness — that +requires receiver-side idempotency keys, which we provide but cannot enforce. +(The enqueue step itself is not yet this durable across nodes — see §4.1.) **P2 — Durable before fast.** Every delivery is persisted before it is attempted. A node crash mid-flight loses at most the in-flight HTTP attempt; the next node picks the row up from the queue. -**P3 — Signed by default.** Every outbound request carries a signature -the receiver can verify with a shared secret. Customers can detect -spoofing without writing custom auth. +**P3 — Signed when configured.** Whenever a webhook's `definition_json` +carries a `secret`, every outbound request for it carries a signature the +receiver can verify with that shared secret (see §6). The `secret` field is +optional, not auto-generated — a webhook created without one is delivered +unsigned. Customers can detect spoofing without writing custom auth, once +they set a secret. -**P4 — Safe egress.** The dispatcher refuses to send to localhost, -private IP ranges, or the metadata endpoints of common cloud providers -unless the operator explicitly allowlists them. Webhooks are a textbook -SSRF vector; the runtime defends against it. +**P4 — Safe egress.** Webhooks are a textbook SSRF vector, and the design +goal is to refuse localhost, private IP ranges, and cloud-provider metadata +endpoints by default. **This is not shipped yet** — see §7: the dispatcher +today POSTs to whatever URL is configured, with no blocklisting. Treat +webhook URL configuration as privileged until this lands. **P5 — Observable.** Every delivery attempt is visible to the owner of -the webhook: status, response code, latency, retry schedule, manual -redelivery. The Studio surfaces this without custom UI code by reusing -the standard object/view machinery. +the webhook: status, response code, retry schedule, manual redelivery. +(Per-attempt latency is computed in memory during the HTTP call but is not +persisted to `sys_http_delivery` or surfaced today.) The Studio surfaces this +without custom UI code by reusing the standard object/view machinery. ## 3. Data model @@ -80,7 +86,7 @@ CRUD, permissions, audit, and Studio UI without bespoke code. The subscription record. One row per "I want webhook X to fire for object Y". The full transport configuration (headers, secret, timeout, method) is carried in `definition_json`, a serialised `Webhook` JSON (canonical schema: -`WebhookSchema` in `@objectstack/spec/automation/webhook`). +`WebhookSchema`, exported from `@objectstack/spec/automation`). | Field | Type | Notes | |-------------------|-----------|------------------------------------------------------------------------------------| @@ -93,7 +99,7 @@ in `definition_json`, a serialised `Webhook` JSON (canonical schema: | `method` | text | HTTP method. Default `POST`. | | `description` | textarea | Free-text description. | | `active` | boolean | Inactive webhooks are skipped by the dispatcher. Default `true`. | -| `definition_json` | textarea | Serialised `Webhook` JSON (`@objectstack/spec/automation/webhook`) — carries the full headers / auth / retry / payload config, including the signing `secret`, custom `headers`, and `timeoutMs`. | +| `definition_json` | textarea | Serialised `Webhook` JSON (`WebhookSchema` from `@objectstack/spec/automation`) — carries the full headers / auth / retry / payload config, including the signing `secret`, custom `headers`, and `timeoutMs`. | | `created_at` | datetime | Standard audit columns. | | `updated_at` | datetime | | @@ -142,8 +148,8 @@ writable. | `response_code` | number | Last HTTP status code received. | | `response_body` | textarea | Truncated to the first 16 KB. | | `error` | textarea | Last transport-level error (DNS, connect, timeout). | -| `created_at` | number | Epoch ms. | -| `updated_at` | number | Epoch ms. | +| `created_at` | datetime | Native TIMESTAMP column — written as a `Date`, not an epoch-ms number. | +| `updated_at` | datetime | Native TIMESTAMP column — written as a `Date`, not an epoch-ms number. | > **Why store full payload?** Receivers may be down for hours; we must > retry the *exact* bytes we promised to send. Recomputing payload from @@ -155,9 +161,9 @@ Five stages, each implemented as a thin layer over an existing primitive. ``` ┌─────────────────────────────────────────────────────────────────┐ -│ 1. Event eventBus.emit('data:account:updated', payload, │ -│ { scope: 'cluster', deliverySemantics: 'at-least- │ -│ once', partitionKey: payload.id }) │ +│ 1. Event realtimeService.publish({ type, object, payload, │ +│ timestamp }) — plain in-process pub/sub; no │ +│ cluster / retry options are attached (see §4.1) │ └──────────────────────────┬──────────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────────┐ @@ -184,21 +190,42 @@ Five stages, each implemented as a thin layer over an existing primitive. ### 4.1 Stage 1 — Event emission -Producers (CRUD handlers, flow nodes, manual API triggers) emit a -**cluster-scoped, at-least-once** event with the record id as -`partitionKey`. These three settings together guarantee: - -- Every node sees the event (subscribers may live anywhere). -- Even on producer crash mid-emit, the event survives. -- Two rapid updates to the same record arrive at the subscriber - in order, so no "stale → fresh → stale" replay reaches the receiver. +Producers (the ObjectQL engine's insert/update/delete handlers) call +`IRealtimeService.publish(event)` after the write commits, where `event` is a +plain `{ type, object, payload, timestamp }` record — e.g. +`{ type: 'data.record.updated', object: 'account', payload: { recordId, +changes, after }, timestamp: }`. + +> **Not yet cluster-aware.** The only shipped `IRealtimeService` implementation +> is `InMemoryRealtimeAdapter`, an in-process, single-node pub/sub with no +> persistence — its own documentation calls this out as the "v1 deployment +> contract: single-instance only" (`packages/services/service-realtime/src/realtime-service-plugin.ts`). +> Concretely, today: +> - There is no `eventBus.emit()` API and no `scope` / `deliverySemantics` / +> `partitionKey` options on the call — `publish()` takes only the event. +> - Events published on one node are **not** delivered to subscribers +> (including the webhook auto-enqueuer) on another node; cross-node fan-out +> is future work. +> - Delivery is synchronous and unpersisted before Stage 3 — if the process +> crashes between the write and `publish()` returning, the event does not +> survive. +> - There is no explicit `partitionKey` ordering primitive; two updates to the +> same record only stay in order because a single node runs JavaScript +> single-threaded. +> +> Everything from Stage 3 onward (the `sys_http_delivery` outbox and +> `HttpDispatcher`) is durable and cluster-aware as described below — the gap +> is upstream, between the CRUD write and the enqueue in Stage 2. ### 4.2 Stage 2 — Matching -The subscriber is a single cluster-wide service -(`clusterScope: 'cluster', leaderStrategy: 'idempotent-broadcast'` — -running on every node is fine because the INSERT in stage 3 is keyed by -`(event_id, webhook_id)` UNIQUE, so duplicates collapse). +The subscriber (`AutoEnqueuer`) is a plain service registered per-node via +`ctx.registerService('webhook.autoEnqueuer', ...)` — it runs independently on +every node with no `clusterScope` / `leaderStrategy` annotation, subscribing to +its own node-local realtime events only (see §4.1). Duplicate enqueues (e.g. +from a periodic cache refresh re-matching the same event) still collapse +safely because the Stage 3 INSERT is keyed by the `(source, dedup_key)` +UNIQUE constraint. For each incoming event the subscriber: @@ -285,7 +312,7 @@ on top: "object": "account", "recordId": "acc_123", "action": "updated", - "timestamp": 1730000000000 + "timestamp": "2024-10-27T00:00:00.000Z" // ...remaining fields from the originating event payload } ``` @@ -295,7 +322,9 @@ Notes: - **`object`** — short object name the event came from. - **`recordId`** — id of the affected record. - **`action`** — `created` / `updated` / `deleted` / `undeleted`. -- **`timestamp`** — event timestamp (epoch ms). +- **`timestamp`** — event timestamp, an ISO 8601 string (passed through + unchanged from the originating realtime event's `timestamp` field — not + an epoch-ms number). - Any additional fields the event carried (e.g. record snapshot data) are spread in after these four. @@ -419,30 +448,29 @@ A precise table of what the runtime promises and what it does not. | Failure | Guarantee | |------------------------------------------|-------------------------------------------------------------| -| Producer node crashes mid-emit | Event durably in transport bus (at-least-once), redelivered on producer restart. | +| Producer node crashes mid-emit | **Not durable today.** The realtime bus (`InMemoryRealtimeAdapter`) is an unpersisted, in-process pub/sub — an event lost before Stage 3's INSERT is gone, not redelivered (see §4.1). | | Subscriber node crashes after persist | Row exists in `sys_http_delivery`, another node picks it up. | | Dispatcher node crashes mid-HTTP | Row stays `in_flight` with `claimed_by`; it reverts to `pending` after the claim TTL and is re-posted. The TTL derives from the dispatcher tick (`intervalMs`, default 500ms): `lockTtlMs = 5 × intervalMs`, `claimTtlMs = 2 × lockTtlMs` (so ~5s at defaults), all configurable via `HttpDispatcherOptions`. | -| Receiver returns 5xx | Retry per backoff schedule until `maxAttempts`. | +| Receiver returns 5xx | Retry per backoff schedule until the fixed 8-attempt budget is exhausted (§4.5). | | Receiver returns 4xx | Treated as terminal — no retry, status `dead` immediately. Exception: 408 / 429 are retried. | | Receiver returns 2xx | `status = success`, no more attempts. | | DNS / connect / TLS error | Treated as 5xx — retry per backoff. | | Timeout (per-attempt) | Treated as 5xx — retry per backoff. | | Network partition between dispatcher and DB | Worker pauses; row stays `pending`. On reconnect, normal claim resumes. | -| Two events for the same record arrive out of order | `partitionKey` ordering at the event bus prevents this. Receiver still sees deliveries in emit order. | +| Two events for the same record arrive out of order | No explicit ordering primitive; holds today only because a single node runs the emit → match → enqueue path synchronously (see §4.1). Not guaranteed across nodes. | | Duplicate delivery (at-least-once) | Receiver gets the same `X-Objectstack-Delivery` id twice. Must dedupe on that header — we provide the key, can't enforce. | ## 13. Plugin location & layering ``` packages/plugins/plugin-webhooks/ -├── src/ -│ ├── webhook-outbox-plugin.ts # plugin: registers sys_webhook + nav, -│ │ # starts the auto-enqueuer, mounts redeliver -│ ├── sys-webhook.object.ts # ObjectSchema.create() — sys_webhook config -│ ├── auto-enqueuer.ts # realtime data.record.* → enqueue onto outbox -│ └── schema.ts # shared types -└── test/ - └── … +└── src/ + ├── webhook-outbox-plugin.ts # plugin: registers sys_webhook + nav, + │ # starts the auto-enqueuer, mounts redeliver + ├── sys-webhook.object.ts # ObjectSchema.create() — sys_webhook config + ├── auto-enqueuer.ts # realtime data.record.* → enqueue onto outbox + ├── auto-enqueuer.test.ts # tests are colocated in src/, not a separate test/ dir + └── schema.ts # shared types ``` The delivery runtime — `sys_http_delivery`, `HttpDispatcher`, the HTTP sender, @@ -475,7 +503,8 @@ Every design choice matches one or more established systems: - **AWS EventBridge / SQS** — at-least-once durability, dead-letter queue model (our `status = dead`). - **Kubernetes admission webhooks** — egress validation and TLS-only - enforcement. + enforcement (design inspiration only — per §7 this is not yet enforced + by our dispatcher). ## 15. Migration plan diff --git a/content/docs/automation/workflows.mdx b/content/docs/automation/workflows.mdx index 11f06aeb7e..c4ea232e64 100644 --- a/content/docs/automation/workflows.mdx +++ b/content/docs/automation/workflows.mdx @@ -109,7 +109,7 @@ Approvals are Flow nodes: type: 'approval', label: 'Manager Approval', config: { - approvers: [{ type: 'user', value: '${record.owner_manager_id}' }], + approvers: [{ type: 'field', value: 'owner_manager_id' }], behavior: 'unanimous', approvalStatusField: 'approval_status', lockRecord: true, diff --git a/content/docs/concepts/architecture.mdx b/content/docs/concepts/architecture.mdx index 074094bc41..7d6127c9c0 100644 --- a/content/docs/concepts/architecture.mdx +++ b/content/docs/concepts/architecture.mdx @@ -195,7 +195,7 @@ export const HighValueCustomerFlow = defineFlow({ }); ``` -See the [Automation Protocol](/docs/protocol/objectos) for the full Flow node and edge reference. +See the [Flow Metadata reference](/docs/automation/flows) for the full Flow node and edge reference. ObjectOS **orchestrates** these rules at runtime, independent of the data structure or UI. @@ -456,9 +456,11 @@ export const OpportunityKanbanView = defineView({ { field: 'customer' }, ], // Kanban-specific config: group columns by the stage field. + // `columns` here lists the fields shown on each card (required). kanban: { groupByField: 'stage', summarizeField: 'amount', + columns: ['title', 'amount', 'customer'], }, }, }); diff --git a/content/docs/concepts/index.mdx b/content/docs/concepts/index.mdx index efd1b465fa..e6ab829667 100644 --- a/content/docs/concepts/index.mdx +++ b/content/docs/concepts/index.mdx @@ -133,11 +133,11 @@ This ensures ObjectStack apps can run on Node.js + PostgreSQL today, Python + SQ | Layer | Responsibility | Example | | :--- | :--- | :--- | | **Protocol** | Defines capabilities | A row-level-security policy slot (`operation` + `using` clause) | -| **App** | Defines business logic | `{ operation: 'select', using: "role = 'admin'" }` | -| **Engine** | Enforces the logic | Compiles the `using` condition into a SQL `WHERE` clause | +| **App** | Defines business logic | `{ operation: 'select', using: "role == 'admin'" }` | +| **Engine** | Enforces the logic | Compiles the `using` condition into a query filter | -CRUD permissions (`allowRead`, `allowEdit`, …) are simple booleans — they grant or deny an operation. Record-level *conditional* access is a separate mechanism: [Row-Level Security](/docs/concepts/architecture) policies, whose `using` clause is a SQL-like (PostgreSQL-compatible) predicate over context variables such as `current_user.roles` and `current_user.id`. +CRUD permissions (`allowRead`, `allowEdit`, …) are simple booleans — they grant or deny an operation. Record-level *conditional* access is a separate mechanism: [Row-Level Security](/docs/concepts/architecture) policies, whose `using` clause is a CEL predicate over context variables such as `current_user.roles` and `current_user.id`. ### Single Source of Truth diff --git a/content/docs/concepts/metadata-driven.mdx b/content/docs/concepts/metadata-driven.mdx index d0eeb92a0e..41b663f080 100644 --- a/content/docs/concepts/metadata-driven.mdx +++ b/content/docs/concepts/metadata-driven.mdx @@ -136,7 +136,7 @@ DELETE /api/v1/data/task/:id Your entire business logic lives in: - Object definitions (`.object.ts`) - View configurations (`.view.ts`) -- Workflow rules (`.workflow.ts`) +- Automation flows (`.flow.ts`) and lifecycle hooks (`.hook.ts`) The Kernel simply **interprets** these definitions. @@ -181,7 +181,7 @@ const FieldSchema = z.object({ type Field = z.infer; // Derived: JSON Schema (for IDE autocomplete) -const jsonSchema = zodToJsonSchema(FieldSchema); +const jsonSchema = z.toJSONSchema(FieldSchema); ``` ### 3. Technology Agnostic diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index 26d168225c..651b4785fd 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -49,10 +49,10 @@ Reads walk top-to-bottom: the first non-null layer wins. Writes always route to | Primitive | Package | Purpose | | :--- | :--- | :--- | -| **Repository** | `@objectstack/metadata-core` | CRUD + watch interface over a single metadata source. `InMemoryRepository` and `LayeredRepository` ship from `@objectstack/metadata-core`; `FileSystemRepository` from `@objectstack/metadata-fs`; `SysMetadataRepository` from `@objectstack/objectql`. | +| **Repository** | `@objectstack/metadata-core` | CRUD + watch interface over a single metadata source. `InMemoryRepository` and `LayeredRepository` ship from `@objectstack/metadata-core`; `FileSystemRepository` from `@objectstack/metadata-fs`; `SysMetadataRepository` from `@objectstack/metadata-protocol` (re-exported by `@objectstack/objectql` for back-compat, per [ADR-0076](https://github.com/objectstack-ai/framework/blob/main/docs/adr/0076-objectql-core-tiering.md)). | | **Change Log** | `@objectstack/metadata-core` | Append-only log of every mutation, tagged with a monotonic `seq`. Watchers can replay from any `since`. | -| **Cache** | `@objectstack/metadata` | In-memory snapshot of the registry, keyed by `MetaRef`. Invalidated by change-log events. | -| **Registry** | `@objectstack/metadata` | Typed registry the Kernel and plugins query. Built from the cache. | +| **Cache** | `@objectstack/metadata-core` | In-memory snapshot of the registry, keyed by `MetaRef`. Invalidated by change-log events. | +| **Registry** | *(per consumer)* | Not a single package — each consumer owns a typed projection over the cache (e.g. `SchemaRegistry` in `@objectstack/objectql`), per [ADR-0008 §2.9](https://github.com/objectstack-ai/framework/blob/main/docs/adr/0008-metadata-repository-and-change-log.md#29-registry-layer). | `MetaRef = (type, name, org)`. As of [ADR-0008 §0 amendment (2026-04-13)](https://github.com/objectstack-ai/framework/blob/main/docs/adr/0008-metadata-repository-and-change-log.md#0-2026-04-13-amendment--drop-project-and-branch-from-metaref), `project` and `branch` are removed from the runtime tuple. Project survives only as an artifact-packaging concept on the `objectstack.json` envelope; branching is left to Git. @@ -62,8 +62,8 @@ Reads walk top-to-bottom: the first non-null layer wins. Writes always route to When a user edits a view in Studio: -1. Studio calls `PUT /api/v1/metadata/views/case_grid` (REST). -2. `protocol.ts:saveMetaItem()` validates against `MetadataTypeRegistry.allowOrgOverride` (see [overlay whitelist](#overlay-whitelist)). +1. Studio calls `PUT /api/v1/meta/view/case_grid` (REST). +2. `protocol.ts:saveMetaItem()` validates against `MetadataTypeRegistryEntry.allowOrgOverride` (see [overlay whitelist](#overlay-whitelist)). 3. If allowed, the call lands on `MetadataRepository.put(ref, body, { parentVersion, actor })`. 4. The repository: - Verifies `parentVersion` matches the current head (`ConflictError` on mismatch). @@ -97,7 +97,8 @@ In shared-database multi-tenancy, **most metadata types must not be per-org cust | Type | `allowOrgOverride` | Rationale | | :--- | :---: | :--- | | `view`, `dashboard`, `report`, `email_template` | ✅ | Pure rendering. Per-org customization is safe. | -| `flow`, `agent` | ✅ | Per-org overlays are allowed for automation and agent definitions. | +| `flow` | ✅ | Per-org overlays are allowed for automation definitions. | +| `agent` | ❌ | Agents are platform-owned and closed to third parties (ADR-0063 §2) — no per-org agent fork. | | `permission`, `role`, `profile` | ✅ | Per-org overlays are allowed; tenant-level controls layer on top. | | `object`, `field` | ❌ | Defines the table schema. Overriding would break existing data. | | `datasource` | ❌ | Connection strings; multi-tenant isolation is enforced at a higher layer. | @@ -138,15 +139,16 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s | Component | State | | :--- | :--- | -| `InMemoryRepository`, `FileSystemRepository`, `LayeredRepository` | ✅ Shipped (`@objectstack/metadata-core`) | +| `InMemoryRepository`, `LayeredRepository` | ✅ Shipped (`@objectstack/metadata-core`) | +| `FileSystemRepository` | ✅ Shipped (`@objectstack/metadata-fs`) | | Change log + `seq` (per-org, monotonic) | ✅ Shipped | | SSE bridge (`/api/v1/dev/metadata-events`, `event: metadata-change`) | ✅ Shipped | | Studio `useMetadataHmr` + `HmrStatusBadge` | ✅ Shipped | | Console dev-mode HMR reloader (`MetadataHmrReloader`) | ✅ Shipped | -| `SysMetadataRepository` (overlay over `sys_metadata`) | ✅ Shipped (`@objectstack/objectql`) | +| `SysMetadataRepository` (overlay over `sys_metadata`) | ✅ Shipped (`@objectstack/metadata-protocol`, re-exported by `@objectstack/objectql`) | | `LayeredRepository(SysMeta + artifact)` composition | ✅ Shipped | | `protocol.ts:saveMetaItem` routed through `SysMetadataRepository.put` | ✅ Shipped (PR-10d.6, flag removed) | -| `sys_metadata_history` table (durable, org-keyed change log) | ✅ Shipped (`@objectstack/objectql`) | +| `sys_metadata_history` table (durable, org-keyed change log) | ✅ Shipped (`@objectstack/metadata-protocol`) | | Cache + Registry refactor against `MetadataRepository` | ⏳ Post-M0 | > **Cross-replica overlay sync is out of scope.** Single-instance deployments @@ -165,7 +167,7 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s |:---|:---|:---| | `defineView(...)` / `defineFlow(...)` / any source file → compiled into `dist/objectstack.json` | ❌ Never. Loaded into the in-memory registry on boot; refreshed via HMR in dev. | ❌ The artifact's own version history *is* Git. The metadata layer does not duplicate it. | | Editing a `.json` under `//.json` (FS overlay, e.g. `/view/case_grid.json`) | ❌ FS layer is independent of DB. | ✅ Appended to the change log at `/.objectstack/.log/main.jsonl` by `FileSystemRepository`. | -| Studio inline edit, or `PUT /api/v1/metadata/...` (REST) on an `allowOrgOverride: true` type | ✅ Written by `SysMetadataRepository.put()` as an **overlay row** scoped to `organization_id`. | ✅ Appended to `sys_metadata_history` (per-org `event_seq`) in the **same transaction** as the `sys_metadata` write (single-instance scope; no cross-replica push). | +| Studio inline edit, or `PUT /api/v1/meta/...` (REST) on an `allowOrgOverride: true` type | ✅ Written by `SysMetadataRepository.put()` as an **overlay row** scoped to `organization_id`. | ✅ Appended to `sys_metadata_history` (per-org `event_seq`) in the **same transaction** as the `sys_metadata` write (single-instance scope; no cross-replica push). | | Deploying a new build (new `dist/objectstack.json`) | ❌ The artifact is loaded into memory, not synced into `sys_metadata`. | ❌ Use Git tags / your deployment platform's release log; that's where artifact "version history" lives. | ### Why artifact never enters the database diff --git a/content/docs/concepts/north-star.mdx b/content/docs/concepts/north-star.mdx index 599ddb18a2..16569a9d0f 100644 --- a/content/docs/concepts/north-star.mdx +++ b/content/docs/concepts/north-star.mdx @@ -50,7 +50,7 @@ This framework repo owns: - kernel/runtime/bootstrap in `packages/core` and `packages/runtime` - REST, ObjectQL, metadata, services, plugins, adapters, CLI, examples, docs - bundled console integration in `packages/console` -- docs site in `apps/docs` and account app in `apps/account` +- docs site in `apps/docs` and account app in `packages/apps/account` Cloud control-plane distribution, public SaaS operations, and ObjectUI source development live outside this repo. diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index 5f9f1476fd..5c5b6dfc53 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -223,7 +223,7 @@ export default defineStack({ datasources: { analytics: { driver: 'postgres', - config: { connection: process.env.ANALYTICS_URL }, + config: { url: process.env.ANALYTICS_URL }, }, cache: { driver: 'mongodb', diff --git a/content/docs/data-modeling/field-type-decision-tree.mdx b/content/docs/data-modeling/field-type-decision-tree.mdx index e59215374a..159bc0d314 100644 --- a/content/docs/data-modeling/field-type-decision-tree.mdx +++ b/content/docs/data-modeling/field-type-decision-tree.mdx @@ -253,5 +253,5 @@ Can't find your use case above? Check this table: | "Is it for AI/ML?" | `vector` | -**Still unsure?** Start with `text` for strings or `number` for numerics. You can change the field type later, but ObjectStack does not auto-convert existing data: compatible changes pass cleanly, while narrowing or incompatible changes surface a build-time warning that existing values may not convert cleanly. +**Still unsure?** Start with `text` for strings or `number` for numerics. You can change the field type later, but ObjectStack does not auto-convert existing data: a small set of compatible narrowings (e.g. `text` → `textarea`/`markdown`/`html`/`code`, `date` ↔ `datetime`) pass cleanly, while other type changes are rejected with a `destructive_change` error when you save the updated object — existing values may not convert cleanly. Re-submit the save with `?force=true` to proceed anyway. diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index c956eb5224..8c906eb9d1 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -324,7 +324,6 @@ Parent-child relationship (cascading delete by default). |:---|:---|:---|:---| | `reference` | `string` | **required** | Target (master) object name | | `referenceFilters` | `string[]` | — | Filters applied to lookup dialogs (e.g. `"active = true"`) | -| `writeRequiresMasterRead` | `boolean` | — | Require read access on master to write detail | | `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'cascade'` | Behavior when parent is deleted (master-detail cascades unless set to `restrict`) | | `inlineEdit` | `boolean \| 'grid' \| 'form'` | — | Edit child records inline on the parent create/edit form (`true` = auto-pick, `'grid'`, or `'form'`) | | `inlineColumns` | `array` | — | Optional explicit inline grid columns | diff --git a/content/docs/data-modeling/formulas.mdx b/content/docs/data-modeling/formulas.mdx index 9a152b3a51..37e3e9bdc9 100644 --- a/content/docs/data-modeling/formulas.mdx +++ b/content/docs/data-modeling/formulas.mdx @@ -11,8 +11,8 @@ piece of metadata needs to compute a value or evaluate a condition: - **Formula fields** (`type: 'formula'`) - **Predicates** — validation `condition`, sharing `condition`, field conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`), - view section/column visibility (`visibleOn`), action `disabled`, view filter - `criteria`, hook `condition`, flow decisions + view section/column visibility (`visibleOn`), action `disabled`, + hook `condition`, flow decisions - **Dynamic seed values** — fixtures whose value depends on the install-time clock or identity context @@ -24,8 +24,9 @@ plus the ObjectStack standard library. > **Why CEL?** Formal grammar, abundant public training corpus (so AI authors > emit it natively), AST-first persistence, and sandboxed execution with > bounded cost. ObjectStack does **not** ship a Salesforce-flavor DSL — the -> previous custom 22-function engine was deleted in M9. See -> [north-star §8](/docs/concepts/north-star) "No private expression DSL". +> previous custom 22-function engine was deleted in M9. See the +> [north-star](/docs/concepts/north-star) principles on typed metadata over +> custom DSLs. --- @@ -86,8 +87,13 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; export const Invoice = ObjectSchema.create({ name: 'invoice', - titleFormat: tmpl`Invoice {{record.invoice_no}} – {{record.customer.name}}`, + nameField: 'display_title', // ADR-0079 — the record title is a designated field; composite titles migrate off the deprecated `titleFormat` to a text formula fields: { + // Composite record title as a text formula, surfaced via `nameField` above. + display_title: Field.formula({ + returnType: 'text', + expression: F`"Invoice " + string(record.invoice_no) + " – " + record.customer.name`, + }), total: Field.formula({ returnType: 'number', expression: F`record.subtotal + record.subtotal * record.tax_rate`, @@ -112,7 +118,8 @@ so the field silently never computes. To declare what the formula returns, set **`returnType`** (`'number' | 'text' | 'boolean' | 'date'`); it is inferred and stamped automatically by the AI build path, and consumers (dashboard measures, formatting) read it instead of re-parsing the expression. The CEL source is the -**`expression`** key (the `formula` alias is normalized to it on save). +**`expression`** key — it is the only key the schema and runtime read for a +formula's source; there is no separate `formula` source key. The three CEL tagged-template helpers — `cel`, `F` (formula alias), `P` @@ -493,4 +500,4 @@ validator backs `objectstack build` and metadata registration (and a planned - [Seed Data](/docs/data-modeling/seed-data) — using `cel\`...\`` for dynamic install-time values - [Hook Bodies](/docs/automation/hook-bodies) — when to use a JS hook vs a CEL `condition` - [`packages/formula/`](https://github.com/objectstack-ai/framework/tree/main/packages/formula) — engine + stdlib source -- [north-star §8](/docs/concepts/north-star) — anti-pattern: private DSLs +- [North Star](/docs/concepts/north-star) — architecture principles (typed metadata over custom DSLs) diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx index b010f605df..11aee7471a 100644 --- a/content/docs/data-modeling/queries.mdx +++ b/content/docs/data-modeling/queries.mdx @@ -199,6 +199,14 @@ position from the previous page. There is no `keyset`/`after` query property. } ``` + +This object form of a field node isn't wired up for top-level `fields` projection. When the +object's schema is registered, the engine's unknown-field filter compares each entry against +the schema's field names via `String(f)`, so an object entry never matches and is silently +dropped from the projection — the aliased field is simply missing from results, no error. Use +`expand` to pull in a relationship's fields instead. + + --- ## Expand (Related Records) @@ -243,6 +251,13 @@ applied on this path. | `array_agg` | Collect into array | `{ function: 'array_agg', field: 'tag', alias: 'all_tags' }` | | `string_agg` | Concatenate strings | `{ function: 'string_agg', field: 'name', alias: 'names' }` | + +`count_distinct` / `array_agg` / `string_agg` are only fully supported on the MongoDB driver. +The SQL driver's aggregate function mapper throws `Unsupported aggregate function` for all +three (only `count`/`sum`/`avg`/`min`/`max` are mapped), and the in-memory driver's aggregator +silently returns `null` for them. Avoid these three on SQL- or memory-backed objects. + + ### Aggregation Example ```typescript @@ -258,10 +273,26 @@ applied on this path. } ``` + +`having` is accepted by `QuerySchema` but is not enforced by the query engine today. +`EngineAggregateOptions` (the type `ObjectQL.aggregate()` actually takes) has no `having` +field, and both the REST `findData()` dispatcher and `ObjectQL.aggregate()` build their +driver-facing query from only `where` / `groupBy` / `aggregations` — a `having` clause is +silently dropped before it reaches any driver. No driver (SQL, in-memory, MongoDB) filters +on it either. Post-filter grouped results on the client until this is wired up. + + --- ## Joins + +`joins` is defined in `QuerySchema` (`JoinNodeSchema`) and the SQL driver advertises +`supports.joins: true`, but no driver's `find()` actually executes a join today — the SQL, +in-memory, and MongoDB drivers all ignore the `joins` array (there is no `.join()`/`.leftJoin()` +call in the SQL driver's query builder). Use `expand` for relationship traversal instead. + + ### Join Types | Type | Description | @@ -328,10 +359,25 @@ applied on this path. | `language` | `string` | Language for stemming/stopwords | | `highlight` | `boolean` | Return highlighted matches | + +Only `query` and `fields` are implemented. The engine expands `search` into a driver-agnostic +`$and`-of-`$or`-of-`$contains` filter (ADR-0061) — `fuzzy`, `operator`, `boost`, `minScore`, +`language`, and `highlight` are accepted by `QuerySchema` but read nowhere in +`expandSearchToFilter()` / `normalizeSearch()`, so they have no effect. Multiple search terms +are always AND-ed regardless of `operator`. + + --- ## Window Functions + +`windowFunctions` is only reachable by calling the SQL driver's `findWithWindowFunctions()` +method directly. `ObjectQL.find()` / `.aggregate()` and the `POST /api/v1/data/:object/query` +route never call it, so sending `windowFunctions` through the standard client/REST query path +has no effect. + + | Function | Description | |:---|:---| | `row_number` | Sequential row number within partition | @@ -377,6 +423,13 @@ applied on this path. } ``` + +The top-level `distinct: true` flag is defined in `QuerySchema` but isn't applied by any +driver's `find()` (SQL, in-memory, and MongoDB all ignore it). A separate +`driver.distinct(object, field)` method exists on the SQL and in-memory drivers, but it isn't +called by `ObjectQL.find()`/`.aggregate()`, so it isn't reachable through a normal query. + + ### Group By with Having ```typescript @@ -391,6 +444,11 @@ applied on this path. } ``` + +As noted under [Aggregations](#aggregations) above, `having` is not currently enforced — +it's dropped before reaching the aggregation engine or any driver. + + --- ## Common Query Patterns diff --git a/content/docs/data-modeling/relationships.mdx b/content/docs/data-modeling/relationships.mdx index 632d61237c..0f5f2c1589 100644 --- a/content/docs/data-modeling/relationships.mdx +++ b/content/docs/data-modeling/relationships.mdx @@ -22,15 +22,25 @@ fields: { ### Filtered Lookups -Reference records that meet criteria: +Reference records that meet criteria, using `lookupFilters` for static conditions and +`dependsOn` to scope candidates by another field on the same record: ```typescript contact: Field.lookup('contact', { label: 'Contact', - referenceFilters: ['account = {account}', 'is_active = true'], + dependsOn: ['account'], + lookupFilters: [ + { field: 'is_active', operator: 'eq', value: true }, + ], }) ``` + + The legacy `referenceFilters: string[]` property (e.g. `['is_active = true']`) is accepted + by the schema but is **not** read by the record-picker UI — it filters nothing. Use the + structured `lookupFilters` (`{ field, operator, value }`) and `dependsOn` shown above instead. + + ### Self-Referencing Lookups Create hierarchies: diff --git a/content/docs/data-modeling/schema-design.mdx b/content/docs/data-modeling/schema-design.mdx index 16c7f9bcc6..ddbd41c89a 100644 --- a/content/docs/data-modeling/schema-design.mdx +++ b/content/docs/data-modeling/schema-design.mdx @@ -27,7 +27,7 @@ export const MyObject = ObjectSchema.create({ description: 'Description...', // Help text // Display configuration - titleFormat: '{{record.field1}} - {{record.field2}}', + nameField: 'field1', // Canonical title field (ADR-0079) highlightFields: ['field1', 'field2', 'field3'], // Fields definition @@ -55,7 +55,8 @@ export const MyObject = ObjectSchema.create({ | `pluralLabel` | string | Plural display name | `'Accounts'` | | `icon` | string | Icon identifier | `'building'` | | `description` | string | Help text | `'Companies...'` | -| `titleFormat` | string | Record title template (`{{record.field}}` interpolation) | `'{{record.name}} - {{record.id}}'` | +| `nameField` | string | Canonical primary title field — the stored field used as the record display name (ADR-0079); the deprecated alias `displayNameField` is still accepted | `'name'` | +| `titleFormat` | string | Deprecated (ADR-0079 → `nameField`). Render-only title template (`{{record.field}}` interpolation); an explicit `nameField` now takes precedence | `'{{record.name}} - {{record.id}}'` | | `highlightFields` | string[] | Most-important fields, in priority order (default columns, cards, previews, detail highlight strip; ADR-0085 — formerly `compactLayout`; the old spelling was retired and is now rejected) | `['name', 'status']` | ### Enable Features @@ -284,7 +285,7 @@ here (ADR-0085). ❌ **DON'T:** - Create too many indexes (slows writes) - Put indexes on low-cardinality fields -- Use SOQL in loops +- Run ObjectQL queries inside loops (N+1 queries) ### 5. Validation @@ -348,6 +349,11 @@ export const Account = ObjectSchema.create({ label: 'Account Owner', required: true, }), + + is_active: Field.boolean({ + label: 'Active', + defaultValue: true, + }), }, indexes: [ diff --git a/content/docs/data-modeling/seed-data.mdx b/content/docs/data-modeling/seed-data.mdx index f0e9ae1203..7787a09400 100644 --- a/content/docs/data-modeling/seed-data.mdx +++ b/content/docs/data-modeling/seed-data.mdx @@ -68,7 +68,7 @@ record (matched by `externalId`). | `insert` | Create only, throw on duplicate | Append-only tables, audit logs | | `update` | Update only, skip if not found | Migration patches on existing rows | | `ignore` | Create if new, silently skip duplicates | Bootstrap data that must not overwrite user edits | -| `replace` | Delete ALL records then insert | Cache / lookup tables rebuilt on each run | +| `replace` | Insert without checking for an existing match — the seed loader does **not** delete anything itself; the target table must already be empty (or cleared by the caller) | Cache / lookup tables rebuilt from an already-truncated table | ### `upsert` — Recommended Default @@ -97,10 +97,12 @@ defineSeed(SystemRole, { }); ``` -### `replace` — Full Table Rebuild +### `replace` — Insert Into an Already-Cleared Table ```typescript -// ⚠️ Deletes ALL records in the object before inserting. +// ⚠️ The seed loader does NOT delete existing records for you — `replace` +// always inserts. Truncate/clear the table yourself before this seed runs, +// or every load will attempt to insert duplicate rows. // Only use for cache or lookup tables with no user-generated data. defineSeed(ExchangeRateCache, { externalId: 'key', @@ -238,54 +240,69 @@ Available in the seed CEL context: `isBlank(v)`, `coalesce(v, fallback)` - **Scope:** `os.user`, `os.org`, `os.env` -### Binding records to a user (`os.user`) +### Owner-style fields (`owner_id`, `created_by`, `assigned_to`) -Many objects have a **required** owner lookup — `owner_id`, `created_by`, -`assigned_to`. To seed such a record, bind it to a user with `cel\`os.user.id\``. -This is the single canonical convention; there is no `currentUser()`, `@admin`, -or similar special syntax. +Many objects have an owner lookup — `owner_id`, `created_by`, `assigned_to`. +On a fresh boot there are no human users yet — seed data loads *before* the +first sign-up — so the platform does **not** mint a placeholder system user to +own these fields. The recommended pattern is to **leave owner-style fields +unset** in the record: ```typescript defineSeed(Project, { externalId: 'code', records: [{ - code: 'bootstrap', - name: 'Bootstrap Project', - owner_id: cel`os.user.id`, // ← bound to the seed identity + code: 'bootstrap', + name: 'Bootstrap Project', + // owner_id intentionally omitted — filled in by the first-admin handoff below }], }); ``` -**Where does `os.user` come from?** On a fresh boot there are no human users yet -— seeding runs *before* the first sign-up. So the runtime provisions a -deterministic, non-loginable **system user** (`usr_system`, role `system`) -*before* any seed runs and binds it to `os.user`. It owns seeded data the way -Salesforce's "Automated Process" user does — it has no credential and **cannot -sign in**. - -- The **human login admin** is created separately (CLI sign-up / first-signup - promotion) through better-auth and need **not** be the seed owner. +**What actually happens to `os.user`?** The seed loader binds `os.user` to +whatever identity the load run carries (`config.identity`). During the normal +boot sequence no identity is supplied, so `os.user` resolves to a null +identity and `cel\`os.user.id\`` evaluates to `null` — the record still seeds +successfully, with the field left `null` rather than the load failing. Once +the first human user is promoted to platform admin, a one-time ownership +handoff re-owns every orphaned row (`owner_id` `null`, or the legacy +`usr_system` value from older databases) to that admin. + +- Because `cel\`os.user.id\`` resolves to `null` before an admin exists, a + **required** (non-nullable) owner-style field must not depend on it — + either leave the field optional/unset (recommended) or supply a literal + value. +- `cel\`os.user.id\`` still resolves to a real user id when the loader is + invoked with an explicit identity (e.g. a re-seed run through tooling that + passes `config.identity`). - `os.org.id` resolves to the current organization; during a per-tenant replay it is that tenant's id, falling back to the load's `organizationId`. -This ordering guarantee means `cel\`os.user.id\`` / `cel\`os.org.id\`` always -resolve at boot — you never have to sequence seeds around user creation. - ### Failure is loud, not silent -If a record uses a CEL value that cannot be resolved — e.g. `cel\`os.user.id\`` -when the system identity could not be provisioned — the record is **not silently -dropped**. The loader counts it as an error, marks the load unsuccessful, and -logs an actionable message: +If a record's CEL expression cannot be evaluated at all (a malformed +expression, or a reference the CEL engine cannot compile), the record is +**not silently dropped**. The loader counts it as an error, marks the load +unsuccessful, and logs an actionable message: + +``` +[SeedLoader] Cannot resolve dynamic seed values for project record #0: . + `os.user.id` resolves to null at seed time (the owning admin does not exist yet) and + owner-style fields are assigned by the first-admin handoff — so a required, non-owner + field must not depend on it. Provide a literal value or make the field optional. +``` + +Separately, if the write itself fails after resolution (e.g. a `NOT NULL` +column rejects a `null` value produced by an unresolved owner field), that +surfaces as its own error: ``` -[SeedLoader] Cannot resolve dynamic seed values for project record #0: - ... Records using cel`os.user.id` / cel`os.org.id` require a seed identity — - ensure a system/admin user exists before seeding. +[SeedLoader] Failed to write project record #0 (code=bootstrap): ``` -Write failures (e.g. a required field still missing after resolution) are -surfaced the same way. Tooling should check `result.success` / `result.errors`. +Both cases increment `result.summary.totalErrored`, add an entry to +`result.errors`, and flip `result.success` to `false`. Tooling should check +`result.success` / `result.errors` rather than assuming a clean run. --- diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index 02d221e72f..a6d011e76c 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -64,9 +64,9 @@ These properties apply to **all** field types and are validated by the base `Fie | Property | Type | Default | Validation Behavior | |:---|:---|:---|:---| -| `format` | `string` | `email` | Validates RFC 5322 email format | +| `format` | `string` | `email` | Validates a basic `local@domain` shape | -**Default constraints:** Must conform to valid email format. +**Default constraints:** Must contain an `@` and a domain with a dot — a lightweight pattern check, not full RFC 5322 validation. ### `url` @@ -80,9 +80,9 @@ These properties apply to **all** field types and are validated by the base `Fie | Property | Type | Default | Validation Behavior | |:---|:---|:---|:---| -| `format` | `string` | `phone` | Validates phone number format | +| `format` | `string` | `phone` | Validates a permissive phone-number character set | -**Default constraints:** Accepts E.164 and common national formats. +**Default constraints:** Accepts digits, `+ ( ) - .` and spaces (minimum 5 characters) — a lenient character-set check, not strict E.164 structural validation. ### `password` @@ -91,7 +91,7 @@ These properties apply to **all** field types and are validated by the base `Fie | `maxLength` | `number` | — | Maximum password length | | `minLength` | `number` | — | Minimum password length | -**Default constraints:** Value is never returned in read operations. Stored as a one-way hash owned by the auth subsystem — for reversible encrypted-at-rest secrets, use the `secret` type instead. +**Default constraints:** Validated the same as `text` (`maxLength`/`minLength` only) — the engine does not automatically hash or mask a `password`-typed field's value on read; even the platform's own credential column (`sys_account.password`) is declared as `Field.text()`, with hashing and verification owned entirely by the auth subsystem (better-auth), not driven by this field type. For a reversible encrypted-at-rest value on your own objects, use the `secret` type instead. --- @@ -209,7 +209,7 @@ These properties apply to **all** field types and are validated by the base `Fie | `options` | `SelectOption[]` | — | **Required.** Static option list | | `defaultValue` | `string` | — | Must match an option `value` | -**Option validation:** Each option `value` must be lowercase (`^[a-z_][a-z0-9_]*$`). +**Option validation:** Each option `value` must be a lowercase system identifier — starts with a letter, then letters/digits/underscores/dots (`^[a-z][a-z0-9_.]*$`), minimum 2 characters. ### `multiselect` @@ -256,7 +256,6 @@ These properties apply to **all** field types and are validated by the base `Fie |:---|:---|:---|:---| | `reference` | `string` | — | **Required.** Parent object name | | `deleteBehavior` | `enum` | `cascade` | Master-detail cascades at runtime unless set to `restrict` | -| `writeRequiresMasterRead` | `boolean` | — | Require read access to master record | **Default constraints:** Enforces parent-child ownership. Child records cascade-delete with the parent by default. @@ -266,7 +265,7 @@ These properties apply to **all** field types and are validated by the base `Fie |:---|:---|:---|:---| | `reference` | `string` | — | **Required.** Self-referencing object name | -**Default constraints:** Self-referencing lookup for hierarchical structures. Prevents circular references. +**Default constraints:** Self-referencing lookup for hierarchical structures. Stored and expanded like a `lookup`; the engine does not run a cycle check on write, so a self-reference chain that loops back on itself is not automatically rejected. --- @@ -432,7 +431,7 @@ These properties apply to **all** field types and are validated by the base `Fie ### `tags` -**Default constraints:** Stored as array of strings. Each tag is trimmed and deduplicated. +**Default constraints:** Stored as array of strings. A lone scalar value is coerced into a single-element array; the engine does not trim or deduplicate entries. --- @@ -467,7 +466,9 @@ For sensitive data, use the properties and types the platform actually enforces: | `trackHistory` | `boolean` | — | Render the field's value changes as entries on the record activity timeline | For reversible encrypted-at-rest values (API keys, tokens, DB passwords), use the -`secret` field type; for credentials, use `password` (one-way hash). See the +`secret` field type — it is the type with an enforced masking/encryption code path. +`password` is validated like plain text and has no built-in hashing or read-masking +(see the `password` section above). See the [Field Type Gallery](/docs/data-modeling/field-types). --- @@ -478,10 +479,10 @@ For reversible encrypted-at-rest values (API keys, tokens, DB passwords), use th |:---|:---|:---| | `text` | — | `maxLength`, `minLength`, `format` | | `textarea` | — | `maxLength`, `minLength` | -| `email` | — | RFC 5322 email format | +| `email` | — | Basic `local@domain` shape (not full RFC 5322) | | `url` | — | Valid URL with protocol | -| `phone` | — | E.164 / national format | -| `password` | — | One-way hash, never returned | +| `phone` | — | Permissive character set, not strict E.164 | +| `password` | — | Validated like `text`; no built-in hashing/masking | | `markdown` | — | `maxLength` | | `html` | — | Sanitized, `maxLength` | | `richtext` | — | Sanitized, `maxLength` | @@ -493,13 +494,13 @@ For reversible encrypted-at-rest values (API keys, tokens, DB passwords), use th | `time` | — | ISO 8601 time, 24h | | `boolean` | — | `true` / `false` only | | `toggle` | — | `true` / `false` only | -| `select` | `options` | Option values must be lowercase snake_case | +| `select` | `options` | Option values must be lowercase system identifiers | | `multiselect` | `options` | Array of valid option values | | `radio` | `options` | Single value from options | | `checkboxes` | `options` | Array of valid option values | | `lookup` | `reference` | Foreign key integrity | | `master_detail` | `reference` | Cascade delete, ownership | -| `tree` | `reference` | Self-referencing, no circular refs | +| `tree` | `reference` | Self-referencing; no automatic cycle check | | `image` | — | `fileAttachmentConfig` for dimensions | | `file` | — | `fileAttachmentConfig` for restrictions | | `avatar` | — | Single image, typically square | @@ -518,5 +519,5 @@ For reversible encrypted-at-rest values (API keys, tokens, DB passwords), use th | `signature` | — | Base64 image, typically immutable | | `qrcode` | — | Format-specific validation | | `progress` | — | Numeric, typically 0–100 | -| `tags` | — | String array, trimmed, deduplicated | +| `tags` | — | String array; no automatic trim/dedup | | `vector` | `dimensions` | Numeric array of exact `dimensions` length | diff --git a/content/docs/deployment/cloud-artifact-api.mdx b/content/docs/deployment/cloud-artifact-api.mdx index 4f5bcf3edc..c45d099ff5 100644 --- a/content/docs/deployment/cloud-artifact-api.mdx +++ b/content/docs/deployment/cloud-artifact-api.mdx @@ -112,7 +112,7 @@ calls, not data-plane calls. |:---|:---| | `packages/cli/src/commands/package/publish.ts` | CLI `os package publish` command and endpoint construction (the legacy `publish.ts` / `rollback.ts` commands were removed — #2237). | | `packages/runtime/src/http-dispatcher.ts` | Kernel-resolution seam for per-request environment resolution. The concrete id/hostname registry ships in the host distribution `@objectstack/objectos-runtime` (not part of this open-source repo). | -| `packages/cloud-connection/src/runtime-config-plugin.ts` | Console runtime-config for active/default environment state. | +| `packages/cloud-connection/src/runtime-config-plugin.ts` | Console runtime-config for default (hostname-resolved) environment state. | | `packages/spec/src/system/environment-artifact.zod.ts` | Normative artifact envelope schema. | --- diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 3945318e18..03034915ac 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -47,9 +47,9 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | Variable | Type | Default | Description | |:---|:---|:---|:---| -| `OS_DATABASE_URL` | url | — | Database connection string (e.g. `file:./data.sqlite`, `postgres://…`, `libsql://…`). | -| `OS_DATABASE_DRIVER` | enum | inferred | Force a specific driver when the URL is ambiguous. `sqlite` \| `postgres` \| `libsql`. | -| `OS_STORAGE_ROOT` | path | `/storage` | Root directory for the local file storage adapter. | +| `OS_DATABASE_URL` | url | — | Database connection string (e.g. `file:./data.sqlite`, `postgres://…`, `mongodb://…`, `memory://`). `libsql://` (Turso) is not dispatched by the open-core runtime — that driver ships separately in the ObjectStack Cloud distribution. | +| `OS_DATABASE_DRIVER` | enum | inferred | Force a specific driver when the URL is ambiguous. `memory` \| `sqlite` \| `sqlite-wasm` \| `postgres` \| `mongodb`. | +| `OS_STORAGE_ROOT` | path | `./.objectstack/data/uploads` | Root directory for the local file storage adapter, relative to the process cwd (used by `os serve`'s default `storage` capability wiring). | | `OS_ARTIFACT_PATH` | path | — | Path or `http(s)://` URL to a compiled `objectstack.json` artifact to boot the kernel from. | --- @@ -229,7 +229,7 @@ Provider credentials keep their upstream names. ObjectStack does not rename |:---|:---|:---|:---| | `OS_MARKETPLACE_CACHE` | enum | `on` | `off` disables the in-memory marketplace listing cache. | | `OS_MARKETPLACE_PUBLIC_BASE_URL` | url | — | Public base URL of the marketplace registry (proxied from this runtime when set). | -| `OS_METADATA_WRITABLE` | boolean | `true` (dev) / `false` (prod) | Allow PATCH / PUT on `/api/v1/metadata/*` at runtime. | +| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,validation`) granted a runtime escape hatch that treats them as `allowOrgOverride: true`, letting artifact-backed items of those protected types be overridden per-org outside their static registry declaration. See [ADR-0005](https://github.com/objectstack-ai/framework/blob/main/docs/adr/0005-metadata-customization-overlay.md). | --- @@ -277,10 +277,9 @@ the hosted ObjectStack Cloud control plane. | `OS_KERNEL_TTL_MS` | number | `900000` | Idle TTL for cached kernels in ms. | | `OS_ENV_CACHE_TTL_MS` | number | `300000` | Env-record cache TTL in ms. | | `OS_ARTIFACT_CACHE_TTL_MS` | number | `300000` | Artifact-record cache TTL in ms. | -| `OS_ARTIFACT_FETCH_TIMEOUT_MS` | number | `30000` | Timeout for remote artifact fetches. `0` disables the timeout. | +| `OS_ARTIFACT_FETCH_TIMEOUT_MS` | number | `60000` | Timeout for remote artifact fetches. Only a positive value is honored — an unset, non-numeric, or `0` value falls back to the 60s default (pass `fetchTimeoutMs: 0` in `artifactSource` config to actually disable the timeout). | | `OS_INLINE_SEED_BUDGET_MS` | number | `8000` | Time budget for synchronous seed execution at boot before deferring to a worker. | | `OS_TENANT_AUDIT` | flag | `1` | Set to `0` to silence the tenant-isolation audit warnings emitted by the SQL driver. | -| `OS_NODE_ID` | string | hostname | Stable node id used by the webhook outbox for distributed locking. | --- @@ -288,7 +287,7 @@ the hosted ObjectStack Cloud control plane. Some env vars accept a legacy alias for compatibility. **Ecosystem-standard names** (e.g. `DATABASE_URL`, `AUTH_SECRET`, `BETTER_AUTH_*`, `PORT`, `CORS_*`, `MCP_SERVER_*`) are permanently accepted and no longer warn. ObjectStack's own former names are deprecated — prefer the canonical `OS_*`. -> **Removed in 11** (rename required): `OS_MULTI_TENANT` → `OS_MULTI_ORG_ENABLED`, `OBJECTSTACK_METADATA_WRITABLE` → `OS_METADATA_WRITABLE`, `AUTH_BASE_URL`/`OS_AUTH_BASE_URL` → `OS_AUTH_URL`. +> **Removed in 11** (rename required): `OS_MULTI_TENANT` → `OS_MULTI_ORG_ENABLED`, `AUTH_BASE_URL`/`OS_AUTH_BASE_URL` → `OS_AUTH_URL`. | Canonical | Legacy | |:---|:---| @@ -305,6 +304,6 @@ Some env vars accept a legacy alias for compatibility. **Ecosystem-standard name | `OS_MCP_SERVER_ENABLED` | `MCP_SERVER_ENABLED` | | `OS_MCP_SERVER_NAME` | `MCP_SERVER_NAME` | | `OS_MCP_SERVER_TRANSPORT` | `MCP_SERVER_TRANSPORT` | -| `OS_NODE_ID` | `OBJECTSTACK_NODE_ID` | +| `OS_METADATA_WRITABLE` | `OBJECTSTACK_METADATA_WRITABLE` | | `OS_HOME` | `OBJECTSTACK_HOME` | | `OS_DEV_CRYPTO_KEY` | `OBJECTSTACK_DEV_CRYPTO_KEY` | diff --git a/content/docs/deployment/index.mdx b/content/docs/deployment/index.mdx index e68ace4afb..9c62b799fb 100644 --- a/content/docs/deployment/index.mdx +++ b/content/docs/deployment/index.mdx @@ -128,7 +128,10 @@ requests and do not run through a target environment kernel. | `OS_PORT` | HTTP listen port. | Third-party provider variables such as `OPENAI_API_KEY`, `TURSO_*`, -`SMTP_*`, `RESEND_API_KEY`, and OAuth client secrets keep their provider names. +`RESEND_API_KEY`, and OAuth client secrets keep their provider names. Mail +settings are the exception — they route through the settings env-override +convention as `OS_MAIL_` (e.g. `OS_MAIL_SMTP_HOST`, `OS_MAIL_SMTP_PORT`), +not a raw `SMTP_*` name. **Set `OS_SECRET_KEY` for any containerized or multi-node deployment.** The diff --git a/content/docs/deployment/single-project-mode.mdx b/content/docs/deployment/single-project-mode.mdx index a36f4560d6..a9479c17e8 100644 --- a/content/docs/deployment/single-project-mode.mdx +++ b/content/docs/deployment/single-project-mode.mdx @@ -88,12 +88,12 @@ needed, the `X-Environment-Id` request header. ```typescript import { createDefaultHostConfig, createStandaloneStack } from '@objectstack/runtime'; -const stack = createStandaloneStack({ +const stack = await createStandaloneStack({ environmentId: 'env_local', artifactPath: './dist/objectstack.json', }); -const host = createDefaultHostConfig({ +const host = await createDefaultHostConfig({ environmentId: 'env_local', artifactPath: './dist/objectstack.json', }); diff --git a/content/docs/deployment/troubleshooting.mdx b/content/docs/deployment/troubleshooting.mdx index dca477db9e..51b0438879 100644 --- a/content/docs/deployment/troubleshooting.mdx +++ b/content/docs/deployment/troubleshooting.mdx @@ -13,7 +13,7 @@ Solutions to the most common issues encountered when working with ObjectStack. ### "Invalid enum value" for field type -**Symptom:** Zod validation fails with `Invalid enum value. Expected 'text' | 'number' | ...` +**Symptom:** Zod validation fails with `Invalid option: expected one of "text"|"number"|...` **Cause:** The `type` value has a typo or uses an unsupported type name. @@ -296,10 +296,12 @@ console.log(field.maxLength?.toString() ?? 'no limit'); ### "Bundle size is too large" +**Cause:** `@objectstack/spec` does not re-export domain schemas (like `FieldSchema` or `QuerySchema`) from its root — only `define*` factory functions and a handful of top-level schemas are available there. Each domain (~400 Zod schema closures total) is only reachable through its subpath, so an import that assumes it's on the root will not resolve. + **Fix:** Import from subpaths instead of the root: ```typescript -// ❌ Imports everything +// ❌ Not exported from the root at all import { FieldSchema, QuerySchema } from '@objectstack/spec'; // ✅ Tree-shakeable subpath imports @@ -307,7 +309,7 @@ import { FieldSchema } from '@objectstack/spec/data'; import { ErrorResponseSchema } from '@objectstack/spec/api'; ``` -Available subpaths: `data`, `api`, `ui`, `system`, `kernel`, `ai`, `automation`, `contracts`, `integration`, `security`, `studio`. +Available subpaths: `data`, `api`, `ui`, `system`, `kernel`, `ai`, `automation`, `contracts`, `integration`, `security`, `studio`, `cloud`, `qa`, `identity`, `shared`. --- diff --git a/content/docs/deployment/vercel.mdx b/content/docs/deployment/vercel.mdx index 7145b59613..0fa55e5e0b 100644 --- a/content/docs/deployment/vercel.mdx +++ b/content/docs/deployment/vercel.mdx @@ -155,9 +155,10 @@ storage for artifacts. | `OS_S3_SECRET_ACCESS_KEY` | optional | Falls back to AWS SDK credential chain | | `OS_S3_FORCE_PATH_STYLE` | optional | `1` for MinIO / self-hosted | -> See [Publish, Versioning & Preview](/docs/deployment/publish-and-preview) for the full -> storage matrix (native S3, Cloudflare R2, MinIO, etc.) and what the cloud -> control plane stores in each bucket. +> The Cloud control plane's storage backend (S3, Cloudflare R2, MinIO, etc.) is +> configured and documented in that separate distribution, not in this framework +> repo. See [Publish, Versioning & Preview](/docs/deployment/publish-and-preview) +> for the package publish/install workflow the control plane exposes to the CLI. **Never commit secrets.** Use Vercel's environment variable UI or the Vercel CLI (`vercel env add`) to configure tokens and S3 credentials.