diff --git a/content/docs/ai/actions-as-tools.mdx b/content/docs/ai/actions-as-tools.mdx index 6c402f9c05..4fe63247ea 100644 --- a/content/docs/ai/actions-as-tools.mdx +++ b/content/docs/ai/actions-as-tools.mdx @@ -237,7 +237,8 @@ migrations) that genuinely need full authority opt in explicitly with never the consequence of a forgotten field. ```typescript -await aiService.chatWithTools(messages, tools, { +await aiService.chatWithTools(messages, { + tools, toolExecutionContext: { actor: { id: currentUser.id, diff --git a/content/docs/ai/connect-mcp.mdx b/content/docs/ai/connect-mcp.mdx index 84ff374aa0..f3f41db772 100644 --- a/content/docs/ai/connect-mcp.mdx +++ b/content/docs/ai/connect-mcp.mdx @@ -68,7 +68,8 @@ copy-paste-ready connect snippets per client), or over REST: ```bash curl -b cookies.txt -X POST https://your-deployment.example.com/api/v1/keys -# → { "key": "osk_..." } — shown once; store it in your secret manager +# → { "success": true, "data": { "key": "osk_...", "prefix": "osk_...", "name": "API Key" } } +# the raw key is shown once — store it in your secret manager ``` Send it on every request in any of three equivalent forms: @@ -87,11 +88,12 @@ run insecurely. ## What the agent gets -Ten data and action tools, generated from your metadata: +Eleven tools, generated from your metadata: | Tool | What it does | |:---|:---| | `list_objects` / `describe_object` | Discover which objects exist and their fields | +| `validate_expression` | Check a CEL expression against an object's schema before authoring it into a formula, validation, or flow condition | | `query_records` / `get_record` | Read data (list queries are capped at 50 rows per page by default) | | `aggregate_records` | Grouped aggregation (registered when the active driver supports it) | | `create_record` / `update_record` / `delete_record` | Write data | diff --git a/content/docs/ai/index.mdx b/content/docs/ai/index.mdx index dbaba14c81..3d6971fabf 100644 --- a/content/docs/ai/index.mdx +++ b/content/docs/ai/index.mdx @@ -70,7 +70,7 @@ flowchart TD ✅ **DO:** - Declare knowledge sources via the Knowledge Protocol and pick the adapter (`memory`, `ragflow`, custom) that fits your data -- Rely on the built-in data tools (`query_records` / `get_record` / `aggregate_data`) for live record access +- Rely on the built-in data tools (`query_records` / `get_record` / `aggregate_records`) for live record access - Let permission-aware retrieval and RLS scope what each user's agent can see - Keep indexed objects in sync via the protocol's event sync @@ -165,7 +165,7 @@ Server-side, the same flow runs through `chatWithTools`, threading the end-user's `ExecutionContext` so tool calls respect row-level security: ```typescript -const reply = await aiService.chatWithTools(messages, tools, { +const reply = await aiService.chatWithTools(messages, { toolExecutionContext: { actor: { id: currentUser.id, name: currentUser.displayName, positions: currentUser.positions, permissions: currentUser.permissions }, conversationId, diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 7bcd2d2d59..0823b85c2a 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -17,7 +17,7 @@ The `@objectstack/client` is the official TypeScript client for ObjectStack. It - **Batch Operations**: Efficient bulk create/update/upsert/delete with transaction support - **Query Builder**: Programmatic query construction with `createQuery()` and `createFilter()` - **Standardized Errors**: Machine-readable error codes with retry guidance -- **Protocol Compliant**: Implements the core API namespaces defined in `@objectstack/spec`, plus additional namespaces (approvals, feed, organizations, projects/environments) +- **Protocol Compliant**: Implements the core API namespaces defined in `@objectstack/spec`, plus additional namespaces (approvals, organizations, projects/environments) ## Installation @@ -76,7 +76,7 @@ async function main() { When you call `client.connect()`, the client: 1. Probes `/api/v1/discovery` first, then falls back to `{origin}/.well-known/objectstack` -2. Parses the discovery response including `routes`, `features`, and `services` +2. Parses the discovery response including `routes`, `capabilities`, and `services` 3. Configures all API route paths dynamically ```typescript @@ -104,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`, `oauth`, `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`, `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 | |:----------|:------:|:--------|:--------| diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index 48ac11845f..9cee9750d0 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -197,8 +197,9 @@ or the analytics service's `cubes` config) — a cube referenced by a query that 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 and dimensions (time-based -dimensions are `dimensions` entries with `type: "time"`). +**Response**: `{ success: true, data: [...] }` where `data` is an array of cube +definitions with measures and dimensions (time-based dimensions are `dimensions` +entries with `type: "time"`). ### `POST /analytics/sql` diff --git a/content/docs/api/error-handling-server.mdx b/content/docs/api/error-handling-server.mdx index df4325096e..e7d784462d 100644 --- a/content/docs/api/error-handling-server.mdx +++ b/content/docs/api/error-handling-server.mdx @@ -202,8 +202,9 @@ export const ValidateTaskData: Hook = { ## 4. Error Logging and Monitoring There is no global `onError` lifecycle event — the hook `HookEvent` enum only -covers `before*` / `after*` of `find`, `insert`, `update`, `delete` (and their -bulk variants). Each `Hook` does expose an `onError` *strategy* field +covers `before*` / `after*` of `find`, `insert`, `update`, `delete`. Bulk +(`multi: true`) writes fire these same events, not separate `*Many` variants. +Each `Hook` does expose an `onError` *strategy* field (`'abort'` — the default — or `'log'`) that decides whether a handler failure aborts the operation or is merely logged. For structured error logging, wrap the risky work in a try/catch inside the handler and log there: diff --git a/content/docs/api/index.mdx b/content/docs/api/index.mdx index 44742af731..35c4254ce1 100644 --- a/content/docs/api/index.mdx +++ b/content/docs/api/index.mdx @@ -105,7 +105,7 @@ Returns the full discovery manifest. **Response**: ```json { - "version": "1.0.0", + "version": "v1", "apiName": "ObjectStack API", "routes": { "data": "/api/v1/data", @@ -124,7 +124,7 @@ Returns the full discovery manifest. "auth": { "enabled": false, "status": "unavailable", "message": "Install plugin-auth to enable" } }, "capabilities": { - "feed": { "enabled": false }, + "cron": { "enabled": false }, "automation": { "enabled": false }, "search": { "enabled": false } } diff --git a/content/docs/api/plugin-endpoints.mdx b/content/docs/api/plugin-endpoints.mdx index 50e8c06094..1b74877521 100644 --- a/content/docs/api/plugin-endpoints.mdx +++ b/content/docs/api/plugin-endpoints.mdx @@ -28,6 +28,10 @@ The following endpoints become available when the corresponding plugin is instal ### Workflow (`/workflow`) — Plugin Required + +Not yet mounted. These routes are declared in the API protocol but the core dispatcher registers no `/workflow` handler and no bundled plugin provides a `workflow` service (only an in-memory dev stub), so they return **404** today. `discovery.services.workflow` reports `unavailable` in a standard install. + + | Method | Endpoint | Description | |:-------|:---------|:------------| | GET | `/workflow/:object/config` | Get workflow configuration | @@ -46,6 +50,10 @@ The automation dispatcher also exposes flow CRUD (`GET`/`POST /automation`, `GET ### Views (`/ui`) — Plugin Required + +Not yet mounted. The `/ui/views` CRUD routes are declared in the API protocol but no handler is registered for them, so they return **404** today (and the `/api/v1/ui` prefix is itself deprecated in favour of `/api/v1/meta/view`). The always-available view **resolver** is a separate route — see the note below. + + | Method | Endpoint | Description | |:-------|:---------|:------------| | GET | `/ui/views/:object` | List views for an object | @@ -60,6 +68,10 @@ The auto-generated (non-CRUD) view resolver `GET /ui/view/:object/:type` is alwa ### Realtime (`/realtime`) — Plugin Required + +Not implemented. There is no realtime HTTP or WebSocket surface: `@objectstack/service-realtime` is an in-process pub/sub bus consumed via `kernel.getService('realtime')`, not over HTTP. The dispatcher has no `/realtime` branch, discovery deliberately omits the route (`features.websockets` is `false`), and the routes below would return **404** (ADR-0076 D12). They document the planned protocol shape only. + + | Method | Endpoint | Description | |:-------|:---------|:------------| | POST | `/realtime/connect` | Establish WebSocket/SSE connection | diff --git a/content/docs/api/wire-format.mdx b/content/docs/api/wire-format.mdx index 97ee9e64a7..c64d36f46a 100644 --- a/content/docs/api/wire-format.mdx +++ b/content/docs/api/wire-format.mdx @@ -254,10 +254,6 @@ The response is the `DeleteDataResponse` envelope: `{ object, id, success }`. } ``` - -**Soft Delete:** If the object enables soft delete (`softDelete: { enabled: true }` in its definition), the record is moved to the trash / recycle bin instead of being permanently deleted. - - --- ## 6. Get Metadata @@ -365,7 +361,7 @@ Field-level failures carry a `fields` array. ```json { - "error": "[Security] Access denied: operation 'update' on object 'task' is not permitted for roles [standard_user]", + "error": "[Security] Access denied: operation 'update' on object 'task' is not permitted for positions [standard_user]", "code": "PERMISSION_DENIED", "object": "task" } diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index de4152496d..bd14e652d9 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -24,7 +24,7 @@ A flow declares `runAs` (ADR-0049), and for approvals this is the decision that - `runAs: 'user'` (default) — the flow's data operations run as the **submitter**, respecting their RLS. Good when the flow only touches records the submitter can already see. - `runAs: 'system'` — **elevated**, bypasses RLS. Needed when the flow must read/write records the submitter can't (e.g. post to a ledger, notify an approver who owns rows the submitter can't see). Declare it **explicitly** so the elevation is visible, not accidental. -A schedule-triggered escalation has no triggering user — so it must be `system` to act at all. +A schedule-triggered escalation has no triggering user, so under the default `runAs: 'user'` its data operations run **unscoped** (elevated, RLS-bypassing) anyway — declare `system` to make that elevation explicit and intended rather than an implicit fail-open (the engine warns, and `os lint` flags the bare shape as `flow-schedule-runas-unscoped`). ### 3. The approval node diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 3144cd4522..26de02c16a 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -678,7 +678,7 @@ export const hotLeadFollowUp: Flow = { type: 'create_record', label: 'Create Follow-up Task', config: { - object: 'task', + objectName: 'task', fields: { subject: 'Follow up on hot lead', related_to: '{record.id}', diff --git a/content/docs/automation/hooks.mdx b/content/docs/automation/hooks.mdx index 4c715bf589..2e57492aad 100644 --- a/content/docs/automation/hooks.mdx +++ b/content/docs/automation/hooks.mdx @@ -85,7 +85,7 @@ ctx = { input, // mutable input — record fields exposed flat (raw wrapper: input.data, input.options) result, // mutable operation result (after* events) previous, // record state before the operation (update/delete) - session, // { userId, tenantId, roles, accessToken, isSystem } + session, // { userId, tenantId, positions, accessToken, isSystem } 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 diff --git a/content/docs/automation/webhooks.mdx b/content/docs/automation/webhooks.mdx index d0259518ac..8746657a35 100644 --- a/content/docs/automation/webhooks.mdx +++ b/content/docs/automation/webhooks.mdx @@ -94,16 +94,16 @@ in `definition_json`, a serialised `Webhook` JSON (canonical schema: | `name` | text | Unique snake_case name — referenced in logs and audit. | | `label` | text | Optional display label. | | `object_name` | text | Short object name whose record events fire this webhook. | -| `triggers` | text | Comma-separated event list: `create,update,delete`. | +| `triggers` | select | Multi-select of `create` / `update` / `delete`, stored as an array (the enqueuer also accepts a legacy comma-separated string). | | `url` | text | External endpoint that receives the POST. | -| `method` | text | HTTP method. Default `POST`. | +| `method` | select | HTTP method — one of `GET` / `POST` / `PUT` / `PATCH` / `DELETE`. Default `POST`. | | `description` | textarea | Free-text description. | | `active` | boolean | Inactive webhooks are skipped by the dispatcher. Default `true`. | | `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 | | -Matching at runtime is purely `object_name` + the comma-separated `triggers` +Matching at runtime is purely `object_name` + the multi-select `triggers` list; the headers, signing secret, and per-attempt timeout are parsed out of `definition_json` when an event is enqueued. There is no per-row org/tenant column, no `events[]` glob field, no stored `retry_policy`, and no @@ -167,7 +167,7 @@ Five stages, each implemented as a thin layer over an existing primitive. └──────────────────────────┬──────────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ 2. Match subscriber filters sys_webhook by events[]/object, │ +│ 2. Match subscriber filters sys_webhook by triggers/object, │ │ multiplies one event into N delivery rows │ └──────────────────────────┬──────────────────────────────────────┘ ▼ @@ -231,7 +231,7 @@ For each incoming event the subscriber: 1. Loads `sys_webhook` rows where `active = true` AND `object_name` matches the event's object AND the event's action is in the row's - comma-separated `triggers`. + `triggers`. 2. For each match, builds the payload (§5). 3. Enqueues a `sys_http_delivery` row (`source = 'webhook'`). @@ -470,7 +470,7 @@ packages/plugins/plugin-webhooks/ ├── 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 + └── schema.ts # public re-export barrel (exports SysWebhook) ``` The delivery runtime — `sys_http_delivery`, `HttpDispatcher`, the HTTP sender, diff --git a/content/docs/concepts/index.mdx b/content/docs/concepts/index.mdx index a69302c4da..091ef17952 100644 --- a/content/docs/concepts/index.mdx +++ b/content/docs/concepts/index.mdx @@ -79,7 +79,7 @@ From this single definition, ObjectStack automatically: ### The Three Truths 1. **The UI is a Projection** — The form is generated from the schema, not hand-coded -2. **The API is a Consequence** — REST/GraphQL endpoints appear automatically from object definitions +2. **The API is a Consequence** — REST endpoints appear automatically from object definitions 3. **The Schema is the Application** — Your entire business logic lives in metadata files ### When to Use Metadata-Driven diff --git a/content/docs/concepts/metadata-driven.mdx b/content/docs/concepts/metadata-driven.mdx index ce8571a80b..26b871be8c 100644 --- a/content/docs/concepts/metadata-driven.mdx +++ b/content/docs/concepts/metadata-driven.mdx @@ -376,7 +376,7 @@ Follow these strict naming conventions for consistency: | **Object Names** (machine names) | `snake_case` | `todo_task`, `project_milestone`, `user_profile` | | **Field Names** (machine names) | `snake_case` | `first_name`, `annual_revenue`, `is_active` | | **Constant Names** (exports) | `PascalCase` | `TodoTask`, `ProjectMilestone`, `UserProfile` | -| **Configuration Keys** (props) | `camelCase` | `maxLength`, `defaultValue`, `referenceFilters` | +| **Configuration Keys** (props) | `camelCase` | `maxLength`, `defaultValue`, `lookupFilters` | **Example:** ```typescript diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index 651b4785fd..ce36ba37d2 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -99,7 +99,7 @@ In shared-database multi-tenancy, **most metadata types must not be per-org cust | `view`, `dashboard`, `report`, `email_template` | ✅ | Pure rendering. Per-org customization is safe. | | `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. | +| `permission`, `position` | ✅ | 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. | diff --git a/content/docs/data-modeling/analytics.mdx b/content/docs/data-modeling/analytics.mdx index 1b6ab3a7db..9146be1126 100644 --- a/content/docs/data-modeling/analytics.mdx +++ b/content/docs/data-modeling/analytics.mdx @@ -60,7 +60,7 @@ export const SalesDataset = defineDataset({ // Aggregatable values — defined ONCE here; referenced everywhere by name. measures: [ { name: 'opp_count', aggregate: 'count' }, - { name: 'revenue', aggregate: 'sum', field: 'amount', format: '$0,0', certified: true }, + { name: 'revenue', aggregate: 'sum', field: 'amount', format: '$0,0' }, { name: 'won_amount', aggregate: 'sum', field: 'amount', filter: { stage: 'closed_won' } }, // Derived measure — references OTHER measures by name only (no raw fields/SQL). { name: 'win_rate', derived: { op: 'ratio', of: ['won_amount', 'revenue'] }, format: '0.0%' }, @@ -81,8 +81,9 @@ export default defineStack({ - **No raw SQL, no hand-authored joins.** The author declares *which* relationships to include; the compiler derives the join from the object graph. -- **`certified: true`** marks a human-blessed metric — the review checkpoint. - Reviewing AI output collapses to "did it use certified measures correctly." +- **Metric certification** — a `certified` flag that marks a measure as a + human-blessed governance checkpoint — is a design goal of ADR-0021 but is **not + yet implemented**; `DatasetMeasureSchema` has no `certified` field today. - **Derived measures** are first-class but *closed*: they reference other measures by name only (`ratio` / `sum` / `difference` / `product`). - **RLS / tenant scoping is enforced by the runtime**, per joined object — never @@ -191,6 +192,8 @@ steps so it could be verified safely: presentation schemas themselves were kept — only their inline query fields went away. -Author new analytics directly in dataset form; reach for a **named** dataset when -a metric is shared or must be certified, and an inline anonymous dataset for a -one-off single-object KPI. +Author new analytics directly in dataset form: define the metric once as a +**named** dataset registered in your stack, then bind reports, dashboard widgets, +and list-view charts to it **by name**. Presentations reference a dataset by its +`name` string — the shipped presentation schemas carry no inline/anonymous +dataset shape, so even a one-off single-object KPI is a small named dataset. diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index 2fadf0bcf4..1819f57c97 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -49,7 +49,9 @@ Drivers can be selected in two ways: Turso / libSQL (`libsql://`, `*.turso.io`) is **not** supported by the -open-source framework — no bundled driver dispatches `libsql://` URLs. +open-source framework. The CLI recognizes these URLs (mapping them to a +`turso` driver kind), but no bundled driver implements it — so they never +actually connect to Turso. ## Supported Drivers diff --git a/content/docs/data-modeling/field-type-decision-tree.mdx b/content/docs/data-modeling/field-type-decision-tree.mdx index 1f96b30c8e..29095e2417 100644 --- a/content/docs/data-modeling/field-type-decision-tree.mdx +++ b/content/docs/data-modeling/field-type-decision-tree.mdx @@ -137,7 +137,7 @@ flowchart TD | `email` | Email address with validation | `email`, `contact_email` | | `url` | Web URL with validation | `website`, `linkedin_url` | | `phone` | Phone number | `phone`, `mobile` | -| `password` | One-way hashed credential (owned by auth subsystem) | `user_password` | +| `password` | Masked on read; stored plaintext at rest (not hashed) — prefer `secret` for real credentials | `user_password` | | `secret` | Reversible encrypted-at-rest value, masked on read | `api_key`, `db_password` | ### Number Types diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 3c73e321b6..5685575359 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -301,7 +301,7 @@ Reference to a record in another object (foreign key). | Property | Type | Default | Description | |:---|:---|:---|:---| | `reference` | `string` | **required** | Target object name (snake_case) | -| `referenceFilters` | `string[]` | — | **Legacy** — accepted by the schema but not read by the record-picker (filters nothing). Use structured `lookupFilters` + `dependsOn` instead; see [Relationships](/docs/data-modeling/relationships) | +| `referenceFilters` | `string[]` | — | **Removed** (#2377, ADR-0049) — no longer a recognized field property (unknown keys are stripped by the schema). Use structured `lookupFilters` + `dependsOn` instead; see [Relationships](/docs/data-modeling/relationships) | | `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'set_null'` | Behavior when referenced record is deleted (a *required* lookup left at the default `set_null` is escalated to `restrict`, since a NOT NULL foreign key cannot be cleared) | ```typescript @@ -323,7 +323,7 @@ Parent-child relationship (cascading delete by default). | Property | Type | Default | Description | |:---|:---|:---|:---| | `reference` | `string` | **required** | Target (master) object name | -| `referenceFilters` | `string[]` | — | **Legacy** — accepted by the schema but not read by the record-picker (filters nothing). Use structured `lookupFilters` + `dependsOn` instead; see [Relationships](/docs/data-modeling/relationships) | +| `referenceFilters` | `string[]` | — | **Removed** (#2377, ADR-0049) — no longer a recognized field property (unknown keys are stripped by the schema). Use structured `lookupFilters` + `dependsOn` instead; see [Relationships](/docs/data-modeling/relationships) | | `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 | @@ -355,43 +355,20 @@ Self-referential hierarchy (e.g., categories, org chart). ## Media Types -All media types support the `fileAttachmentConfig` property: - -| Property | Type | Description | -|:---|:---|:---| -| `maxSize` | `number` | Maximum file size in bytes | -| `minSize` | `number` | Minimum file size in bytes | -| `allowedTypes` | `string[]` | Allowed file extensions | -| `blockedTypes` | `string[]` | Blocked file extensions | -| `allowedMimeTypes` | `string[]` | Allowed MIME types | -| `blockedMimeTypes` | `string[]` | Blocked MIME types | -| `virusScan` | `boolean` | Enable virus scanning | -| `storageProvider` | `string` | Storage provider name | -| `storageBucket` | `string` | Storage bucket name | -| `allowMultiple` | `boolean` | Allow multiple files | -| `versioningEnabled` | `boolean` | Enable file versioning | -| `maxVersions` | `number` | Maximum version count | -| `publicRead` | `boolean` | Publicly accessible | -| `presignedUrlExpiry` | `number` | URL expiry in seconds | +Media fields store a reference to an uploaded file. They take the universal field properties (`multiple` is honored for `file` and `image`); there is no per-field file-attachment config object in the field schema — file constraints and storage are configured at the storage-provider layer, not on the field. ### `image` Image file with optional validation. ```typescript -{ - name: 'photo', label: 'Photo', type: 'image', - fileAttachmentConfig: { maxSize: 5242880, allowedMimeTypes: ['image/png', 'image/jpeg'] } -} +{ name: 'photo', label: 'Photo', type: 'image' } ``` ### `file` Generic file attachment. ```typescript -{ - name: 'attachment', label: 'Attachment', type: 'file', - fileAttachmentConfig: { maxSize: 10485760, virusScan: true } -} +{ name: 'attachment', label: 'Attachment', type: 'file', multiple: true } ``` ### `avatar` @@ -405,20 +382,14 @@ Profile image (typically square, small). Video file attachment. ```typescript -{ - name: 'demo_video', label: 'Demo Video', type: 'video', - fileAttachmentConfig: { maxSize: 104857600, allowedMimeTypes: ['video/mp4'] } -} +{ name: 'demo_video', label: 'Demo Video', type: 'video' } ``` ### `audio` Audio file attachment. ```typescript -{ - name: 'recording', label: 'Recording', type: 'audio', - fileAttachmentConfig: { maxSize: 52428800 } -} +{ name: 'recording', label: 'Recording', type: 'audio' } ``` --- @@ -432,7 +403,6 @@ Calculated field using a CEL expression (see [Expressions](/docs/data-modeling/f |:---|:---|:---|:---| | `expression` | `string \| Expression` | **required** | CEL calculation expression | | `returnType` | `'number' \| 'text' \| 'boolean' \| 'date'` | — | Declared value type of the computed result | -| `dependencies` | `string[]` | — | Fields the formula depends on | ```typescript { name: 'total', label: 'Total', type: 'formula', expression: 'record.quantity * record.unit_price', returnType: 'number' } @@ -635,7 +605,7 @@ Vector embeddings for semantic search and similarity. |:---|:---|:---|:---| | `dimensions` | `number` | **required** | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | -The nested `vectorConfig` object (with `distanceMetric`, `indexed`, `indexType`, …) is retained for back-compat only and is a runtime no-op — set the flat `dimensions` property instead. +An earlier nested `vectorConfig` object was removed — the field builder no longer emits it and it is ignored. Set the flat `dimensions` property instead. ```typescript { name: 'embedding', label: 'Embedding', type: 'vector', dimensions: 1536 } @@ -662,14 +632,13 @@ These properties are available on **all** field types: | `sortable` | `boolean` | `true` | Allow sorting by this field | | `hidden` | `boolean` | `false` | Hide from default views | | `readonly` | `boolean` | `false` | Prevent user edits | -| `index` | `boolean` | `false` | Create database index | | `externalId` | `boolean` | `false` | External system identifier | | `defaultValue` | `any` | — | Default value for new records | | `group` | `string` | — | Field grouping / section | | `inlineHelpText` | `string` | — | Inline help tooltip | | `trackHistory` | `boolean` | — | Render this field's value changes as entries on the record activity timeline | | `requiredPermissions` | `string[]` | — | Capabilities required to read/edit this field (masked on read, denied on write) | -| `dependencies` | `string[]` | — | Names of fields this field depends on (formulas, visibility rules, etc.) | +| `dependsOn` | `string[]` | — | Fields whose values scope this field's options (lookup candidate query; select/radio option gating) | | `visibleWhen` | `Expression` | — | Show field only when predicate is true | | `readonlyWhen` | `Expression` | — | Make field read-only when predicate is true | | `requiredWhen` | `Expression` | — | Require field when predicate is true | diff --git a/content/docs/data-modeling/fields.mdx b/content/docs/data-modeling/fields.mdx index 18fbd99268..587e38904b 100644 --- a/content/docs/data-modeling/fields.mdx +++ b/content/docs/data-modeling/fields.mdx @@ -174,7 +174,7 @@ order: Field.masterDetail('order', { | Property | Type | Description | | :--- | :--- | :--- | | `reference` (1st arg) | `string` | Target object name | -| `referenceFilters` | `string[]` | Filter conditions for the lookup | +| `lookupFilters` | `{ field, operator, value }[]` | Base filters restricting which records are selectable (structured, picker-honored) | | `deleteBehavior` | `enum` | `'set_null'`, `'cascade'`, `'restrict'` | | `inlineEdit` | `boolean \| 'grid' \| 'form'` | Render child records inline on the parent create/edit form (`true` = auto-pick, `'grid'`, or `'form'`) | | `inlineColumns` | `array` | Optional explicit columns for the inline grid | @@ -293,9 +293,10 @@ These properties are available on all field types: | Property | Type | Default | Description | | :--- | :--- | :--- | :--- | | `searchable` | `boolean` | `false` | Include in full-text search | -| `index` | `boolean` | `false` | Create a database index | | `externalId` | `boolean` | `false` | Mark as external system identifier | +Database indexes are declared at the object level in the object's `indexes[]` array — there is no per-field `index` flag. See [Indexing](/docs/data-modeling/indexing). + ### Security | Property | Type | Default | Description | diff --git a/content/docs/data-modeling/formulas.mdx b/content/docs/data-modeling/formulas.mdx index 5bca743cc0..c150b12cc8 100644 --- a/content/docs/data-modeling/formulas.mdx +++ b/content/docs/data-modeling/formulas.mdx @@ -203,7 +203,7 @@ a mistake that would silently evaluate to `null` at runtime is caught before it ships. The same shared validator also runs when a flow is registered (`registerFlow`), so a flow authored dynamically gets the same verdict. Findings come at two severities: **errors** fail the build; **warnings** are advisory -(surfaced, not fatal — promote them with `--strict`). +(surfaced, not fatal — `objectstack validate --strict` promotes them to errors). | Check | Example | Severity | |:---|:---|:---| @@ -520,8 +520,8 @@ But **call sites must not silently swallow that** (ADR-0032): `objectstack build **fails** on an invalid expression (with a located, schema-aware message), and at runtime the flow/rule engines **throw** a loud, attributed error instead of treating a bad expression as `false`/`null`. The same `validateExpression` -validator backs `objectstack build` and metadata registration (and a planned -`validate_expression` agent tool), so an expression is checked before it ships. +validator backs `objectstack build` and metadata registration (and the +agent-callable `validate_expression` tool), so an expression is checked before it ships. --- diff --git a/content/docs/data-modeling/index.mdx b/content/docs/data-modeling/index.mdx index 4fa4713aff..90f1cff123 100644 --- a/content/docs/data-modeling/index.mdx +++ b/content/docs/data-modeling/index.mdx @@ -31,7 +31,7 @@ That one definition is enough to get a persisted table, CRUD + query endpoints, ## What the data layer actually gives you -- **A full spectrum of field types** — from `text`, `currency`, and `lookup`/`master_detail` relationships to `formula`, `summary`, `signature`, and `vector` (with index config for AI embeddings). See the [Field Types gallery](/docs/data-modeling/field-types). +- **A full spectrum of field types** — from `text`, `currency`, and `lookup`/`master_detail` relationships to `formula`, `summary`, `signature`, and `vector` (with `dimensions` config for AI embeddings). See the [Field Types gallery](/docs/data-modeling/field-types). - **Validation as metadata** — required/format rules, CEL script validation with access to `previous.`, uniqueness via indexes, and severity levels — enforced identically in API, UI, and automation. - **CEL expressions** — formula fields and computed defaults share one expression language ([Expressions](/docs/data-modeling/formulas)). - **A compiled query AST** — queries are JSON documents validated against the protocol, then compiled by a driver into native queries with joins, aggregations, window functions, HAVING, and subqueries. The [query cheat sheet](/docs/data-modeling/queries) covers the syntax; the [spec](/docs/protocol/objectql/query-syntax) is normative. diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index cfef157c69..88f119fa82 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -52,7 +52,6 @@ export const Account = ObjectSchema.create({ | `pluralLabel` | `string` | optional | Plural label (e.g. `'Accounts'`) | | `description` | `string` | optional | Developer documentation | | `icon` | `string` | optional | Icon name (Lucide/Material) | -| `tags` | `string[]` | optional | Categorization tags (e.g. `['sales', 'system']`) | ### Data @@ -70,7 +69,6 @@ export const Account = ObjectSchema.create({ | `titleFormat` | `string` | optional | Deprecated (ADR-0079 → `nameField`). Render-only title template (e.g. `'{{record.name}} - {{record.code}}'`); an explicit `nameField` takes precedence | | `highlightFields` | `string[]` | optional | Most-important fields in priority order — default list columns, cards, previews, detail highlight strip (ADR-0085; formerly `compactLayout` — the old spelling was retired and is now rejected) | | `stageField` | `string \| false` | optional | Linear lifecycle field; `false` declares the status field non-linear and suppresses stage heuristics (ADR-0085) | -| `recordName` | `object` | optional | Record name auto-generation config | ### Capabilities (`enable`) @@ -98,8 +96,8 @@ enable: { | `apiEnabled` | `true` | Expose object via automatic APIs | | `apiMethods` | all | Whitelist of allowed API operations | | `files` | `false` | Enable file attachments | -| `feeds` | `false` | Enable social feed and comments | -| `activities` | `false` | Enable tasks and events tracking | +| `feeds` | `true` | Enable social feed and comments | +| `activities` | `true` | Enable tasks and events tracking | | `trash` | `true` | Enable soft delete with restore | | `mru` | `true` | Track Most Recently Used list | | `clone` | `true` | Allow record deep cloning | @@ -120,27 +118,6 @@ tenancy: { } ``` -#### Soft Delete - -```typescript -softDelete: { - enabled: true, - field: 'deleted_at', - cascadeDelete: false, -} -``` - -#### Versioning - -```typescript -versioning: { - enabled: true, - strategy: 'snapshot', // 'snapshot' | 'delta' | 'event-sourcing' - retentionDays: 365, - versionField: 'version', -} -``` - #### Data Lifecycle (Retention & Rotation) Declares how long the object's data lives and how its space is reclaimed @@ -215,24 +192,6 @@ sets years while dev keeps days), row `quotas` and `growth_alert_rows` (observe-and-alert only). `OS_LIFECYCLE_DISABLED=1` disables sweeping entirely. -### Record Name - -Auto-generate unique record identifiers: - -```typescript -recordName: { - type: 'autonumber', - displayFormat: 'ACC-{0000}', // ACC-0001, ACC-0002, ... - startNumber: 1, -} -``` - -| Property | Type | Description | -| :--- | :--- | :--- | -| `type` | `'text' \| 'autonumber'` | Generation mode | -| `displayFormat` | `string` | Pattern (e.g. `'INV-{YYYY}-{0000}'`) | -| `startNumber` | `number` | Starting number (default: 1) | - ### Indexes Optimize query performance: @@ -257,11 +216,8 @@ indexes: [ | Property | Type | Description | | :--- | :--- | :--- | -| `active` | `boolean` | Is object active (default: `true`) | | `isSystem` | `boolean` | System object, protected from deletion (default: `false`) | -| `abstract` | `boolean` | Abstract base, cannot be instantiated (default: `false`) | | `sharingModel` | `enum` | Org-Wide Default record visibility (ADR-0055/0056/0090). Canonical four only: `'private'`, `'public_read'`, `'public_read_write'`, `'controlled_by_parent'` (detail visibility derived from its master). The legacy aliases (`'read'`, `'read_write'`, `'full'`) were removed from the enum (ADR-0090 D4) — authoring rejects them. Unset on a custom object resolves to `'private'` (ADR-0090 D1) | -| `keyPrefix` | `string` | Short prefix for record IDs (e.g. `'001'`) | | `ownership` | `enum` | Record-ownership model: `'user'` (default — injects the reassignable `owner_id` lookup, engaging owner-scoped RLS, "My" views and owner reports), `'org'`, or `'none'` (no per-record owner — Dataverse-style catalog / junction tables, skips `owner_id`). Distinct from the package `own`/`extend` contribution kind. | | `validations` | `ValidationRule[]` | Object-level validation rules (see [Validation](/docs/data-modeling/validation)) | diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx index 67f4f71fad..25d1eee9e2 100644 --- a/content/docs/data-modeling/queries.mdx +++ b/content/docs/data-modeling/queries.mdx @@ -377,7 +377,7 @@ with CJK names are also found by typing their **full pinyin** or **initials**: s matching. This is transparent to queries and clients — the platform maintains a hidden `__search` companion column derived from each object's display/name field and ORs it into the expanded filter; `fields` semantics, `searchableFields`, and drivers are unchanged -(ADR-0097). Relevance ranking and typo tolerance remain Tier-2 (native FTS) and are not +(ADR-0098). Relevance ranking and typo tolerance remain Tier-2 (native FTS) and are not part of this. **Coverage** — every object with a resolvable display/name field gets the companion, diff --git a/content/docs/data-modeling/relationships.mdx b/content/docs/data-modeling/relationships.mdx index 0f5f2c1589..1c05618e56 100644 --- a/content/docs/data-modeling/relationships.mdx +++ b/content/docs/data-modeling/relationships.mdx @@ -36,8 +36,9 @@ contact: Field.lookup('contact', { ``` - 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 + The legacy `referenceFilters: string[]` property (e.g. `['is_active = true']`) was **removed** + (#2377, ADR-0049) and is no longer a recognized field property — the schema strips it as an + unknown key, and it was never read by the record-picker UI, so it filters nothing. Use the structured `lookupFilters` (`{ field, operator, value }`) and `dependsOn` shown above instead. diff --git a/content/docs/data-modeling/schema-design.mdx b/content/docs/data-modeling/schema-design.mdx index ddbd41c89a..93b6b7cec1 100644 --- a/content/docs/data-modeling/schema-design.mdx +++ b/content/docs/data-modeling/schema-design.mdx @@ -97,13 +97,13 @@ To control exactly which fields are matched, set `searchableFields` on the objec ```typescript { name: 'account', - searchable: true, + enable: { searchable: true }, searchableFields: ['name', 'website', 'billing_city'], } ``` -Queries then use the `$search` filter operator to match across `searchableFields` -(e.g. `{ filters: { $search: 'acme' } }`); a view may narrow the set with +Queries then pass a top-level `$search` parameter to match across `searchableFields` +(e.g. `{ $search: 'acme' }`); a request may narrow the set with `$searchFields`. When `searchableFields` is unset, search falls back to the name/title field plus short-text fields. diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index c3c47336ae..acbae82d87 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -27,9 +27,8 @@ These properties apply to **all** field types and are validated by the base `Fie | `unique` | `boolean` | `false` | Enforces database-level uniqueness constraint | | `multiple` | `boolean` | `false` | Stores value as array (applicable for select, lookup, file, image) | | `hidden` | `boolean` | `false` | Excluded from default UI rendering | -| `readonly` | `boolean` | `false` | Blocks user edits in UI forms; server-enforced on update — a non-system write to the field is silently dropped from the payload (insert exempt) | +| `readonly` | `boolean` | `false` | Blocks user edits in UI forms; server-enforced on **both create and update** — a non-system write to the field is silently dropped from the payload (system-context writes such as import/seed/migration are exempt) | | `sortable` | `boolean` | `true` | Whether field appears in list view sort options | -| `index` | `boolean` | `false` | Creates standard database index | | `externalId` | `boolean` | `false` | Marks field as external ID for upsert operations | | `trackHistory` | `boolean` | — | Render this field's value changes as entries on the record activity timeline | | `visibleWhen` | `string \| Expression` | — | CEL predicate; field is shown only when `TRUE` | @@ -91,7 +90,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:** 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. +**Default constraints:** Validated the same as `text` (`maxLength`/`minLength` only). On a generic (non-`better-auth`) object a `password`-typed value is stored **plaintext at rest** but **masked to `••••••••` on read** through the normal query path (ADR-0100) — the engine does not hash or encrypt it. One-way hashing and verification of real login credentials are owned entirely by the auth subsystem (better-auth); its own credential column (`sys_account.password`) is a hashed `Field.text()` column that is exempt from this masking. For a reversible, encrypted-at-rest value on your own objects, use the `secret` type instead. --- @@ -244,7 +243,7 @@ These properties apply to **all** field types and are validated by the base `Fie | Property | Type | Default | Validation Behavior | |:---|:---|:---|:---| | `reference` | `string` | — | **Required.** Target object name | -| `referenceFilters` | `string[]` | — | **Legacy** — schema-accepted but not read by the record-picker; use `lookupFilters` + `dependsOn` | +| `referenceFilters` | `string[]` | — | **Removed** (#2377, ADR-0049) — no longer part of the `Field` schema; use `lookupFilters` + `dependsOn` instead | | `deleteBehavior` | `enum` | `set_null` | `set_null`, `cascade`, or `restrict` | | `multiple` | `boolean` | `false` | Allow multiple references | @@ -275,32 +274,25 @@ These properties apply to **all** field types and are validated by the base `Fie | Property | Type | Default | Validation Behavior | |:---|:---|:---|:---| -| `fileAttachmentConfig` | `object` | — | File size, type, and image dimension rules | | `multiple` | `boolean` | `false` | Allow multiple image uploads | -**Default constraints:** Accepts common image MIME types. Optional dimension and thumbnail validation via `fileAttachmentConfig`. +**Default constraints:** Accepts common image MIME types. The schema has no field-level attachment-config property — `fileAttachmentConfig` was removed in the 16.x line (#2377). ### `file` | Property | Type | Default | Validation Behavior | |:---|:---|:---|:---| -| `fileAttachmentConfig` | `object` | — | Full file upload validation config | | `multiple` | `boolean` | `false` | Allow multiple file uploads | -**Default constraints:** Accepts any file type unless restricted. Supports virus scanning, size limits, and type restrictions. +**Default constraints:** Accepts any file type. The `Field` schema exposes no field-level upload-validation config — `fileAttachmentConfig` (and its `maxSize` / `allowedTypes` / virus-scan sub-keys) was removed in the 16.x line (#2377). File-storage constraints such as virus scanning are configured on the file-storage integration, not on the field. ```typescript -// File field with validation +// File field { name: 'contract_pdf', label: 'Contract', type: 'file', - fileAttachmentConfig: { - maxSize: 10485760, // 10MB - allowedTypes: ['.pdf', '.docx'], - virusScan: true, - virusScanProvider: 'clamav' - } + multiple: false } ``` @@ -310,19 +302,11 @@ These properties apply to **all** field types and are validated by the base `Fie ### `video` -| Property | Type | Default | Validation Behavior | -|:---|:---|:---|:---| -| `fileAttachmentConfig` | `object` | — | File size and type restrictions | - -**Default constraints:** Accepts common video MIME types. Size limits recommended via `fileAttachmentConfig`. +**Default constraints:** Accepts common video MIME types. The field type has no per-field size/type config property in the schema. ### `audio` -| Property | Type | Default | Validation Behavior | -|:---|:---|:---|:---| -| `fileAttachmentConfig` | `object` | — | File size and type restrictions | - -**Default constraints:** Accepts common audio MIME types. Size limits recommended via `fileAttachmentConfig`. +**Default constraints:** Accepts common audio MIME types. The field type has no per-field size/type config property in the schema. --- @@ -332,8 +316,8 @@ These properties apply to **all** field types and are validated by the base `Fie | Property | Type | Default | Validation Behavior | |:---|:---|:---|:---| -| `expression` | `string` | — | **Required.** Formula expression | -| `dependencies` | `string[]` | — | Fields this formula depends on | +| `expression` | `string` | — | **Required.** CEL formula expression | +| `returnType` | `enum` | — | Optional inferred result type: `number`, `text`, `boolean`, or `date` | **Default constraints:** Read-only. Value computed at runtime from `expression`. Not directly writable. @@ -342,8 +326,7 @@ These properties apply to **all** field types and are validated by the base `Fie name: 'full_name', label: 'Full Name', type: 'formula', - expression: 'record.first_name + " " + record.last_name', - dependencies: ['first_name', 'last_name'] + expression: 'record.first_name + " " + record.last_name' } ``` @@ -443,7 +426,7 @@ These properties apply to **all** field types and are validated by the base `Fie |:---|:---|:---|:---| | `dimensions` | `number` | — | **Required.** Vector size (1–10,000) | -**Default constraints:** Must be a numeric array of exactly `dimensions` length. The nested `vectorConfig` object is retained for back-compat only and is a runtime no-op — set the flat `dimensions` property instead. +**Default constraints:** Must be a numeric array of exactly `dimensions` length. Set the flat `dimensions` property directly — the legacy nested `vectorConfig` object was removed in the 16.x line (#2377) and is no longer part of the schema. ```typescript { @@ -466,9 +449,11 @@ 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 — 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 +`secret` field type — it is the type with an enforced encryption/masking code path +(encrypted at rest via `sys_secret`, masked on read). `password` is validated like +plain text and stored plaintext at rest with no built-in hashing or encryption; it is +masked to `••••••••` on read but offers no at-rest protection (see the `password` +section above). See the [Field Type Gallery](/docs/data-modeling/field-types). --- @@ -482,7 +467,7 @@ For reversible encrypted-at-rest values (API keys, tokens, DB passwords), use th | `email` | — | Basic `local@domain` shape (not full RFC 5322) | | `url` | — | Valid URL with protocol | | `phone` | — | Permissive character set, not strict E.164 | -| `password` | — | Validated like `text`; no built-in hashing/masking | +| `password` | — | Validated like `text`; masked on read but plaintext at rest (no hashing/encryption) | | `markdown` | — | `maxLength` | | `html` | — | Sanitized, `maxLength` | | `richtext` | — | Sanitized, `maxLength` | @@ -501,11 +486,11 @@ For reversible encrypted-at-rest values (API keys, tokens, DB passwords), use th | `lookup` | `reference` | Foreign key integrity | | `master_detail` | `reference` | Cascade delete, ownership | | `tree` | `reference` | Self-referencing; no automatic cycle check | -| `image` | — | `fileAttachmentConfig` for dimensions | -| `file` | — | `fileAttachmentConfig` for restrictions | +| `image` | — | Common image MIME types; `multiple` for many | +| `file` | — | Any file type; `multiple` for many (no field-level upload config) | | `avatar` | — | Single image, typically square | -| `video` | — | `fileAttachmentConfig` for size limits | -| `audio` | — | `fileAttachmentConfig` for size limits | +| `video` | — | Common video MIME types | +| `audio` | — | Common audio MIME types | | `formula` | `expression` | Read-only, computed at runtime | | `summary` | `summaryOperations` | Read-only, roll-up from children | | `autonumber` | — | Read-only, auto-incremented | diff --git a/content/docs/data-modeling/validation.mdx b/content/docs/data-modeling/validation.mdx index ba71ad0c27..fdf315b3a6 100644 --- a/content/docs/data-modeling/validation.mdx +++ b/content/docs/data-modeling/validation.mdx @@ -323,7 +323,7 @@ functions available include: |----------|-------------|---------| | `isBlank(x)` | True if null/empty/whitespace | `isBlank(record.phone)` | | `isEmpty(x)` | True if empty collection/string | `isEmpty(record.tags)` | -| `coalesce(a, b, …)` | First non-blank value | `coalesce(record.nickname, record.name)` | +| `coalesce(a, b)` | `a` if non-null, else `b` | `coalesce(record.nickname, record.name)` | | `today()` | Current date | `record.close_date <= today()` | | `now()` | Current datetime | `record.due_at < now()` | | `daysBetween(a, b)` | Whole days between two dates | `daysBetween(record.end_date, today())` | diff --git a/content/docs/deployment/backup-restore.mdx b/content/docs/deployment/backup-restore.mdx index 0797015d46..4ef19779d6 100644 --- a/content/docs/deployment/backup-restore.mdx +++ b/content/docs/deployment/backup-restore.mdx @@ -99,7 +99,7 @@ file storage. ### Boot from the artifact and verify ```bash -OS_ARTIFACT_PATH=./objectstack.json OS_PORT=8080 os start +OS_ARTIFACT_PATH=./dist/objectstack.json OS_PORT=8080 os start curl -fsS http://localhost:8080/api/v1/health curl -fsS http://localhost:8080/api/v1/ready ``` diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 923e35fde6..170fe0958f 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -29,7 +29,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | `OS_BOOT_EMPTY` | flag | `0` | When `1`, boot the kernel with no metadata artifact (CLI internal). | | `OS_MIGRATE_AND_EXIT` | flag | `0` | When `1`, run pending migrations and exit (suitable for one-shot jobs). | | `OS_DISABLE_CONSOLE` | flag | `0` | When `1`, do not mount the Console portal under `/_console`. | -| `OS_EAGER_SCHEMAS` | flag | `0` | When `1`, eagerly materialise all object schemas at boot (slower start, faster first request). | +| `OS_EAGER_SCHEMAS` | flag | `0` | When `1`, evaluate all lazily-built Zod spec schemas eagerly at module load instead of on first use — an emergency rollback of the lazy-schema memory optimization (higher startup RSS). | | `OS_SKIP_SCHEMA_SYNC` | flag | `0` | When `1`, skip the implicit `db:sync` on boot. Use after running migrations manually. | > **Port conflicts behave differently by mode.** In **dev** (`os dev`, or @@ -80,7 +80,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | `GOOGLE_CLIENT_ID` | string | — | Deployment-level Google OAuth client id for the open-source Google login implementation. | | `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. | | `OS_MULTI_ORG_ENABLED` | boolean | `false` | When `true`, expose organization creation/switching UI. When `false`, run in single-tenant mode. | -| `OS_OIDC_PROVIDER_ENABLED` | boolean | `false` | When `true`, expose this instance as an OIDC identity provider. | +| `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. | | `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). | | `OS_TRUSTED_ORIGINS` | csv | — | Comma-separated list of origins permitted for auth callbacks / CSRF. | | `OS_TERMS_URL` | url | — | URL shown next to the "Terms of Service" link on the sign-up form. Empty string hides the link. | @@ -124,7 +124,7 @@ Auth settings precedence: | `OS_EMAIL_PROVIDER` | enum | `log` | Transport. `log` \| `resend` \| `postmark`. `log` (default) prints to stdout without sending. Real SMTP delivery requires the separate `@objectstack/plugin-mail-smtp` package. | | `OS_EMAIL_API_KEY` | string | — | API key for `resend` / `postmark`. | | `OS_EMAIL_FROM` | email | — | Default `From:` address. | -| `OS_EMAIL_RETRIES` | number | `3` | Retry count for transient send failures. | +| `OS_EMAIL_RETRIES` | number | `0` | Retry count for transient send failures (`0` = no retry). | > No SMTP transport ships in the open-core runtime — `OS_EMAIL_PROVIDER` > only materialises `log`, `resend`, or `postmark`. Real SMTP delivery diff --git a/content/docs/deployment/index.mdx b/content/docs/deployment/index.mdx index 64cc9e1856..06d9c3e008 100644 --- a/content/docs/deployment/index.mdx +++ b/content/docs/deployment/index.mdx @@ -129,9 +129,9 @@ requests and do not run through a target environment kernel. Third-party provider variables such as `OPENAI_API_KEY`, `TURSO_*`, `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. +settings are the exception — they route through the email service's +env-override convention as `OS_EMAIL_` (e.g. `OS_EMAIL_PROVIDER`, +`OS_EMAIL_API_KEY`, `OS_EMAIL_FROM`), not a raw `SMTP_*` name. **Every deployed app is AI-operable by default:** an MCP server is served at diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index 6a8ba37459..fbf393c27b 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -115,10 +115,10 @@ docker run -p 8080:8080 \ straight from release storage instead of a mount.) For a self-contained deployable image, extend it. The Dockerfile below (plus -the compose stack in the next section and a `.dockerignore`) ships -ready-to-copy in -[`docker/`](https://github.com/objectstack-ai/framework/tree/main/docker) -— drop the files into your scaffolded project. +the compose stack in the next section and a `.dockerignore`) ships ready-made +in the project scaffold — +[`create-objectstack`](https://github.com/objectstack-ai/framework/tree/main/packages/create-objectstack/src/templates/blank) +(`npm create objectstack`) writes all three into your new project's root. ```dockerfile title="Dockerfile" # ── Build stage: compile TypeScript metadata to the artifact ───────── diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index c41eed768d..5a16ccb74b 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -77,7 +77,7 @@ Control auto-binding with the `membershipPolicy` auth option: | `'invite-only'` | Users are **never** auto-bound; membership comes only from invitations, `add-member`, SSO provisioning, or host hooks. Choose this for a deployment whose end-users are deliberately not teammates. | ```ts -createAuthPlugin({ membershipPolicy: 'invite-only' /* … */ }); +new AuthPlugin({ membershipPolicy: 'invite-only' /* … */ }); ``` > **Note** — In single-org mode, membership does **not** gate data access (there diff --git a/content/docs/deployment/troubleshooting.mdx b/content/docs/deployment/troubleshooting.mdx index 51b0438879..96ff139993 100644 --- a/content/docs/deployment/troubleshooting.mdx +++ b/content/docs/deployment/troubleshooting.mdx @@ -211,7 +211,7 @@ client.data.find('project_task', { /* query */ }); // ❌ View references non-existent object defineStack({ objects: [{ name: 'task', /* ... */ }], - views: [{ name: 'contact_list', object: 'contact', /* ... */ }] // 'contact' not defined! + views: [{ list: { type: 'grid', data: { provider: 'object', object: 'contact' }, columns: ['name'] } }] // 'contact' not defined! }); // ✅ All references resolve @@ -220,7 +220,7 @@ defineStack({ { name: 'task', /* ... */ }, { name: 'contact', /* ... */ } ], - views: [{ name: 'contact_list', object: 'contact', /* ... */ }] + views: [{ list: { type: 'grid', data: { provider: 'object', object: 'contact' }, columns: ['name'] } }] }); ``` @@ -274,7 +274,7 @@ console.log(field.maxLength?.toString() ?? 'no limit'); ### "Query is slow" **Checklist:** -1. **Add indexes** — Set `index: true` on fields used in `where` clauses +1. **Add indexes** — Declare indexes in the object's `indexes[]` array (e.g. `indexes: [{ fields: ['status'] }]`) for fields used in `where` clauses — there is no field-level `index` flag 2. **Limit fields** — Only request fields you need (`fields: ['id', 'name']`) 3. **Use pagination** — Always set `limit` to avoid returning all records 4. **Avoid deep nesting** — Limit nested `$and`/`$or` depth diff --git a/content/docs/deployment/vercel.mdx b/content/docs/deployment/vercel.mdx index af3e1009d9..239ad49292 100644 --- a/content/docs/deployment/vercel.mdx +++ b/content/docs/deployment/vercel.mdx @@ -29,7 +29,7 @@ In Server mode, ObjectStack runs inside Vercel Serverless Functions. API request │ │ │ Serverless Functions │ │ ┌───────────────────────────────────┐ │ -│ │ /api/[...objectstack] │ │ +│ │ /api/index.ts │ │ │ │ → ObjectKernel │ │ │ │ → ObjectQL │ │ │ │ → PostgreSQL / MongoDB / ... │──── External DB │ @@ -43,7 +43,7 @@ In Server mode, ObjectStack runs inside Vercel Serverless Functions. API request This is a valid self-contained pattern when you want to ship a Vite SPA *and* its API from a single Vercel project: the SPA is served as static assets, and a Hono-based serverless function handles `/api/*` requests. -The published ObjectStack Studio/console (`@object-ui/console`) does **not** use this topology. It deploys as a pure static SPA with no `api/` function — it is meant to be embedded in, or pointed at, a separate ObjectStack server via `VITE_SERVER_URL`. For that separate server, prefer the CLI (`objectstack serve`) over a hand-rolled Hono function. +The published ObjectStack Studio/console (`@objectstack/console`) does **not** use this topology. It deploys as a pure static SPA with no `api/` function — it is meant to be embedded in, or pointed at, a separate ObjectStack server via `VITE_SERVER_URL`. For that separate server, prefer the CLI (`objectstack serve`) over a hand-rolled Hono function. **1. Create the kernel singleton** (`api/_kernel.ts` — prefixed with `_` to prevent Vercel from creating a route): diff --git a/content/docs/getting-started/cli.mdx b/content/docs/getting-started/cli.mdx index 483dfd0816..aa0fe3cc4a 100644 --- a/content/docs/getting-started/cli.mdx +++ b/content/docs/getting-started/cli.mdx @@ -133,11 +133,11 @@ os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32) | `-d, --database ` | `OS_DATABASE_URL` | `file:…` / `libsql://` / `postgres://` / `mongodb://` / `memory://` | | `--database-driver ` | `OS_DATABASE_DRIVER` | Force `sqlite` \| `turso` \| `postgres` \| `mongodb` \| `memory` | | `--database-auth-token ` | `OS_DATABASE_AUTH_TOKEN` | libsql/Turso token | -| `--auth-secret ` | `AUTH_SECRET` | Override the dev-fallback secret | +| `--auth-secret ` | `OS_AUTH_SECRET` | Override the dev-fallback secret | | `--environment-id ` | `OS_ENVIRONMENT_ID` | Environment identifier (default `env_local`) | -| `-p, --port ` | `PORT` / `OS_PORT` | Listen port (default `3000`). In dev a busy port auto-hops to the next free one; the banner shows the actual port. | -| `--ui` | — | Force Studio UI on (already on by default in dev) | -| `--no-compile` | — | Skip the auto-compile step (errors if no artifact present) | +| `-p, --port ` | `OS_PORT` / `PORT` | Listen port (default `3000`). In dev a busy port auto-hops to the next free one; the banner shows the actual port. | +| `--ui` | — | Force Console UI on (already on by default in dev) | +| `--compile` | — | Force compiling `objectstack.config.ts` → `dist/objectstack.json` before starting (auto when the artifact is missing; ignored with `--artifact`) | | `-v, --verbose` | — | Verbose output | With a file-backed SQLite database, dev also provisions a sibling @@ -170,7 +170,7 @@ os serve --preset minimal # Skip auto-loaded auth/i18n/ui plugins ``` **Options:** -- `-p, --port ` — Server port (default: env `PORT` or `3000`) +- `-p, --port ` — Server port (default: env `OS_PORT` or `3000`) - `--dev` — Development mode (loads devPlugins, pretty logging) - `--ui / --no-ui` — Toggle Console UI at `/_console/` (default `on`) - `--server / --no-server` — Toggle HTTP server plugin @@ -189,10 +189,10 @@ only gate the **automatic** registration of optional plugins. | Preset | Tiers | Auto-loaded optional plugins | |:---|:---|:---| | `minimal` | `core` | none | -| `default` *(default)* | `core`, `i18n`, `ui`, `ai`, `auth` | i18n service, Studio UI, AI service, Auth + Security + Audit | +| `default` *(default)* | `core`, `i18n`, `ui`, `ai`, `auth` | i18n service, Console UI, AI service, Auth + Security + Audit | | `full` | `core`, `i18n`, `ui`, `ai`, `auth` | currently an alias of `default` — same tiers, no additional plugins | -The `auth` tier requires `AUTH_SECRET` to be set; otherwise `AuthPlugin` +The `auth` tier requires `OS_AUTH_SECRET` to be set; otherwise `AuthPlugin` is skipped with a yellow warning and the `/api/v1/auth/*` endpoints will return 404. To take full control, set `tiers` on the stack config: @@ -285,7 +285,7 @@ os start --database "postgres://user:pass@host:5432/mydb" # Pure env-var style still works (Docker / Fly / k8s friendly) OS_ARTIFACT_PATH=./build/myapp.json \ OS_DATABASE_URL=file:./data/prod.db \ -AUTH_SECRET=… \ +OS_AUTH_SECRET=… \ os start ``` @@ -297,9 +297,9 @@ os start | `-d, --database ` | `OS_DATABASE_URL` | `file:…` / `libsql://` / `postgres://` / `mongodb://` / `memory://` | | `--database-driver ` | `OS_DATABASE_DRIVER` | Force `sqlite` \| `turso` \| `postgres` \| `mongodb` \| `memory` when the URL is ambiguous | | `--database-auth-token ` | `OS_DATABASE_AUTH_TOKEN` | Auth token for libsql/Turso | -| `--auth-secret ` | `AUTH_SECRET` | Secret for `@objectstack/plugin-auth`; without it `/api/v1/auth/*` is skipped (server still runs) | +| `--auth-secret ` | `OS_AUTH_SECRET` | Secret for `@objectstack/plugin-auth`; without it `/api/v1/auth/*` is skipped (server still runs) | | `--environment-id ` | `OS_ENVIRONMENT_ID` | Environment identifier (default `env_local`) | -| `-p, --port ` | `PORT` / `OS_PORT` | Listen port (default `3000`). **Production fails loudly if the port is busy** — see note below. | +| `-p, --port ` | `OS_PORT` / `PORT` | Listen port (default `3000`). **Production fails loudly if the port is busy** — see note below. | | `--ui` / `--no-ui` | — | Mount the Console portal at `/_console/`. Enabled by default (so you can install marketplace apps); pass `--no-ui` to disable it. | | `-v, --verbose` | — | Verbose output | @@ -307,7 +307,7 @@ os start > hops to the next free port for local convenience), `os start` exits with an > error if its resolved port is in use. A silently drifted port would break > your reverse-proxy upstream, `OS_AUTH_URL` callbacks, and `OS_TRUSTED_ORIGINS` -> (CORS). **Pin the port explicitly** (`PORT=8080 os start`) and keep +> (CORS). **Pin the port explicitly** (`OS_PORT=8080 os start`) and keep > `OS_AUTH_URL` / `OS_TRUSTED_ORIGINS` in sync when you change it. **Resolution priority (artifact):** `--artifact` > `OS_ARTIFACT_PATH` > `/dist/objectstack.json`. @@ -320,7 +320,7 @@ os start - Runs standalone boot mode with one active environment. **Authentication:** -The `auth` capability is auto-loaded only when both (1) the artifact declares `requires: [..., 'auth']` **and** (2) a secret is provided via `--auth-secret` or `AUTH_SECRET`. Without a secret, `AuthPlugin` is **silently skipped with a warning** — the server still boots and serves data/REST routes, only `/api/v1/auth/*` (login/register) is omitted. This is intentional: `os start` is happy to run an unauthenticated, internal-network deployment. +The `auth` capability is auto-loaded only when both (1) the artifact declares `requires: [..., 'auth']` **and** (2) a secret is provided via `--auth-secret` or `OS_AUTH_SECRET`. Without a secret, `AuthPlugin` is **silently skipped with a warning** — the server still boots and serves data/REST routes, only `/api/v1/auth/*` (login/register) is omitted. This is intentional: `os start` is happy to run an unauthenticated, internal-network deployment. **`os start` vs `os serve`:** `os serve` boots from `objectstack.config.ts` (TypeScript source). @@ -444,8 +444,8 @@ os info --json # JSON output for tooling Logic: 5 Flows 5 Agents 2 APIs Objects: - account (16 fields, own) — Account - contact (24 fields, own) — Contact + account (16 fields, user) — Account + contact (24 fields, user) — Contact ... Loaded in 90ms @@ -606,7 +606,7 @@ os register --url https://api.example.com In an interactive terminal, login uses a browser-based device flow by default: the CLI prints a one-time verification URL, opens the browser, and polls until -you approve access in Studio. +you approve access in your browser. ```bash os login diff --git a/content/docs/getting-started/common-patterns.mdx b/content/docs/getting-started/common-patterns.mdx index 16cb44b65c..fe8cefe8e0 100644 --- a/content/docs/getting-started/common-patterns.mdx +++ b/content/docs/getting-started/common-patterns.mdx @@ -155,7 +155,7 @@ export const taskViews = defineView({ type: 'grid', data: { provider: 'object', object: 'task' }, columns: ['title', 'status', 'priority', 'assigned_to', 'due_date'], - filter: [['status', '!=', 'closed']], + filter: [{ field: 'status', operator: 'not_equals', value: 'closed' }], sort: [ { field: 'priority', order: 'desc' }, { field: 'due_date', order: 'asc' } diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx index 7413536ead..692b1c76be 100644 --- a/content/docs/getting-started/examples.mdx +++ b/content/docs/getting-started/examples.mdx @@ -38,7 +38,7 @@ metadata, run `os validate`, and verify in the Console — the loop in **Looking for a production server shape?** Build an artifact with `os compile`, - then run it with `os start` or `os serve --artifact`. The framework repo no + then run it with `os start` or `os serve`. The framework repo no longer contains a separate `apps/objectos` production host. @@ -84,7 +84,7 @@ examples/app-todo/ │ │ └── task.report.ts # Tabular + summary reports │ ├── datasets/ # Saved query datasets for charts │ ├── flows/ -│ │ └── task.flow.ts # Auto-assignment automation +│ │ └── task.flow.ts # Reminder, escalation & recurrence flows │ ├── translations/ # Locale bundles │ └── data/ │ └── index.ts # Seed data via defineSeed() @@ -206,7 +206,7 @@ const tasks = defineSeed(Task, { export const TodoSeedData = [tasks]; ``` -**3. The server auto-detects** what you need: ObjectQL engine → InMemory driver → Hono HTTP server → REST API at `/api/v1/data/todo_task`. +**3. The server auto-detects** what you need: ObjectQL engine → SQLite driver → Hono HTTP server → REST API at `/api/v1/data/todo_task`. --- @@ -299,7 +299,7 @@ import CrmApp from '../examples/app-crm/objectstack.config'; import TodoApp from '../examples/app-todo/objectstack.config'; // composeStacks merges objects, apps, and other metadata from each stack. -// The combined manifest is selected per the `manifest` option; the first +// The combined manifest is selected per the `manifest` option; the last // stack's manifest is used by default. Duplicate object names throw unless // you pass an `objectConflict` strategy ('override' | 'merge'). export default composeStacks([CrmApp, TodoApp]); diff --git a/content/docs/getting-started/glossary.mdx b/content/docs/getting-started/glossary.mdx index 2665580041..3d5fc37ec3 100644 --- a/content/docs/getting-started/glossary.mdx +++ b/content/docs/getting-started/glossary.mdx @@ -44,7 +44,7 @@ Infrastructure services including Event Bus, Job Scheduling, Translation (i18n), Plugin system and runtime management. Includes Plugin lifecycle, Manifest definition, Logger configuration, and Runtime Context. The core of the control layer. ### AI Protocol -Artificial intelligence capabilities including AI Agents, RAG pipelines, Natural Language Query (NLQ), Predictive models, Cost tracking, and Orchestration. +Platform AI primitives: AI Agents, Skills, Tools, Conversations, a multi-provider Model Registry, Embedding and vector-store references, Knowledge sources, and MCP server bindings, plus Usage (token accounting and per-call cost). Scope is deliberately narrow — higher-level concerns such as NLQ services, RAG pipeline orchestration, and predictive pipelines were removed in v1 and are meant to be built on top of these primitives. ### API Protocol External communication layer including REST contracts, API discovery, Realtime subscriptions (WebSocket/SSE), and Routing configuration. diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 6f41dffae8..b47fc9fc5b 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -164,7 +164,7 @@ Access control, permissions, and row-level security. | Protocol | Source File | Key Schemas | Purpose | |:---------|:-----------|:------------|:--------| | **[Permission](/docs/references/security/permission)** | `permission.zod.ts` | ObjectPermission | Object-level permissions | -| **[RLS](/docs/references/security/rls)** | `rls.zod.ts` | RowLevelSecurity | Row-level security filters | +| **[RLS](/docs/references/security/rls)** | `rls.zod.ts` | RowLevelSecurityPolicy | Row-level security filters | | **[Sharing](/docs/references/security/sharing)** | `sharing.zod.ts` | SharingRule | Record sharing rules | | **[Territory](/docs/references/security/territory)** | `territory.zod.ts` | Territory | Territory-based access | @@ -236,7 +236,7 @@ Testing and quality assurance. { maxLength: 100, defaultValue: 'example', - referenceFilters: ['active = true'] + inlineHelpText: 'Enter a value' } ``` @@ -275,10 +275,11 @@ import { ManifestSchema, PluginSchema } from '@objectstack/spec/kernel'; import { LoggerConfigSchema, JobSchema } from '@objectstack/spec/system'; // AI protocols -import { AgentSchema, RAGPipelineConfigSchema } from '@objectstack/spec/ai'; +import { AgentSchema, ModelRegistrySchema } from '@objectstack/spec/ai'; -// Types -import type { Field, Object, Query, View } from '@objectstack/spec'; +// Types (subpath imports — the root package does not export types) +import type { Field, ServiceObject, QueryAST } from '@objectstack/spec/data'; +import type { View } from '@objectstack/spec/ui'; ``` ## Search Tips diff --git a/content/docs/getting-started/validating-metadata.mdx b/content/docs/getting-started/validating-metadata.mdx index 6c87b369d8..a2fe8fbca9 100644 --- a/content/docs/getting-started/validating-metadata.mdx +++ b/content/docs/getting-started/validating-metadata.mdx @@ -50,7 +50,7 @@ dangling one. ## The one gate, two entry points -`os validate` and `os build` (alias `os compile`) run the **same** validator: +`os validate` and `os build` (alias of `os compile`) run the **same** validator: | | `os validate` | `os build` | |---|---|---| diff --git a/content/docs/getting-started/your-first-project.mdx b/content/docs/getting-started/your-first-project.mdx index 3917a742ee..6008a4a9e0 100644 --- a/content/docs/getting-started/your-first-project.mdx +++ b/content/docs/getting-started/your-first-project.mdx @@ -50,8 +50,8 @@ The scaffolder (`create-objectstack`) does four things: project conventions — including the rule to run `npm run validate` after every metadata change. -Steps 3–4 cost nothing if you never use an agent; skip them with -`--skip-skills` if you prefer. +Steps 3–4 cost nothing if you never use an agent; skip step 3 (the skills +bundle) with `--skip-skills` if you prefer — `AGENTS.md` is always written. ### Templates @@ -84,7 +84,7 @@ the npm package and always works offline. ``` my-app/ ├── objectstack.config.ts # defineStack() — the single entry point -├── objectstack.manifest.json # name, namespace, version +├── objectstack.manifest.json # name, namespace, specVersion ├── package.json ├── tsconfig.json ├── AGENTS.md # conventions for coding agents diff --git a/content/docs/kernel/architecture.mdx b/content/docs/kernel/architecture.mdx index 7061d299b6..eed58bf653 100644 --- a/content/docs/kernel/architecture.mdx +++ b/content/docs/kernel/architecture.mdx @@ -45,7 +45,7 @@ After all plugins have successfully initialized, the optional `start(ctx)` hook - HTTP servers start listening. - Background jobs are scheduled. -Plugins may also implement an optional `destroy()` hook, which the kernel calls in reverse dependency order during `shutdown()` to release resources. +Plugins may also implement an optional `destroy()` hook, which the kernel calls in reverse registration order during `shutdown()` to release resources. ## ObjectKernel Public API @@ -68,7 +68,7 @@ class ObjectKernel { /** * Gracefully shut down the kernel, calling each plugin's destroy() hook - * in reverse dependency order. + * in reverse registration order. */ shutdown(): Promise; diff --git a/content/docs/kernel/cluster.mdx b/content/docs/kernel/cluster.mdx index 9424e39992..34dd10cdf7 100644 --- a/content/docs/kernel/cluster.mdx +++ b/content/docs/kernel/cluster.mdx @@ -226,8 +226,12 @@ registration (`ServiceMetadata.cluster` / `ServiceFactoryRegistration.cluster` > **Status: not yet implemented (Phase 4).** The schema accepts > `clusterScope` / `leaderStrategy` annotations today, but no runtime code -> yet consumes them or performs leader election. The behaviour below is the -> intended contract once Phase 4 (see §10) lands. +> yet consumes those annotations to wire leadership automatically — the four +> behaviours below are the intended Phase 4 (see §10) contract. Leader +> election itself already works via the `Lock` primitive (`service-job`'s +> cron scheduler calls `cluster.lock.acquire(...)` directly so only one node +> fires each scheduled job); what Phase 4 adds is the kernel doing it *for +> you* from a service's declared `clusterScope`. When a service declares `clusterScope: 'cluster'`, the runtime will automatically: @@ -367,8 +371,8 @@ To keep an in-process metadata cache coherent today, subscribe to the metadata change channel via PubSub and invalidate on each notification: ```ts -ctx.cluster.pubsub.subscribe('metadata.changed', (payload) => { - // payload: { originNode?, type, event } +ctx.cluster.pubsub.subscribe('metadata.changed', (msg) => { + const payload = msg.payload // { originNode?, type, event } if (payload.type === 'object') { myObjectCache.invalidate(payload.event) } @@ -473,11 +477,11 @@ v4 consumers. 1. Add `kernel/cluster.zod.ts` — schemas for `EventScope`, `DeliverySemantics`, `ClusterScope`, `LeaderStrategy`, `ClusterConfig`. -2. Extend `events/core.zod.ts` `EventMetadataSchema` with optional - `scope`, `deliverySemantics`, `partitionKey`. +2. Extend `events/core.zod.ts` `EventMetadataSchema` with an optional nested + `cluster` object (`scope`, `deliverySemantics`, `partitionKey`). 3. Extend `kernel/service-registry.zod.ts` `ServiceMetadataSchema` and - `ServiceFactoryRegistrationSchema` with optional `clusterScope`, - `leaderStrategy`. + `ServiceFactoryRegistrationSchema` with an optional nested `cluster` + object (`clusterScope`, `leaderStrategy`). 4. Add optional `cluster` field to `stack.zod.ts`. No runtime changes. After this phase the protocol *describes* cluster diff --git a/content/docs/kernel/contracts/auth-service.mdx b/content/docs/kernel/contracts/auth-service.mdx index a658c322a8..a95e9fb0ba 100644 --- a/content/docs/kernel/contracts/auth-service.mdx +++ b/content/docs/kernel/contracts/auth-service.mdx @@ -82,8 +82,8 @@ export interface AuthUser { email: string; /** Display name */ name: string; - /** Assigned role identifiers */ - roles?: string[]; + /** Assigned position identifiers */ + positions?: string[]; /** Current tenant identifier (multi-tenant) */ tenantId?: string; } @@ -94,7 +94,7 @@ export interface AuthUser { | `id` | `string` | Unique user identifier | | `email` | `string` | User's email address | | `name` | `string` | Display name | -| `roles` | `string[]?` | Assigned role identifiers | +| `positions` | `string[]?` | Assigned position identifiers | | `tenantId` | `string?` | Current tenant identifier (multi-tenant) | --- diff --git a/content/docs/kernel/contracts/data-engine.mdx b/content/docs/kernel/contracts/data-engine.mdx index 0000aeefb0..9e4752f3bf 100644 --- a/content/docs/kernel/contracts/data-engine.mdx +++ b/content/docs/kernel/contracts/data-engine.mdx @@ -28,7 +28,7 @@ import type { EngineAggregateOptions, EngineCountOptions, DataEngineRequest, -} from '@objectstack/spec'; +} from '@objectstack/spec/data'; export interface IDataEngine { // Query @@ -333,16 +333,11 @@ const result = await engine.execute?.( ## Error Codes -| Code | Description | -|:---|:---| -| `RECORD_NOT_FOUND` | No record exists with the given ID | -| `DUPLICATE_KEY` | A unique constraint was violated | -| `VALIDATION_ERROR` | Data does not match the object schema | -| `REFERENCE_ERROR` | A lookup/master_detail target record does not exist | -| `DELETE_RESTRICTED` | Cannot delete; child records exist (master_detail) | -| `TRANSACTION_FAILED` | Transaction was rolled back due to an error | -| `QUERY_TIMEOUT` | Query exceeded the configured timeout | -| `BULK_PARTIAL_FAILURE` | Some records in a bulk operation failed | +| Code | HTTP | Description | +|:---|:---|:---| +| `RECORD_NOT_FOUND` | 404 | No record exists with the given ID | +| `VALIDATION_FAILED` | 400 | Data does not match the object schema | +| `DELETE_RESTRICTED` | 409 | Cannot delete; dependent child records reference it via a `restrict` delete behavior | --- @@ -360,7 +355,6 @@ The following legacy parameter names are accepted by the RPC layer for backward | `sort` | `orderBy` | Array of `{ field, order }` | | `skip` | `offset` | Number | | `populate` | `expand` | Record of field → QueryAST | -| `top` | `limit` | Number (OData alias, still supported) | The deprecated `DataEngineQueryOptionsSchema`, `DataEngineUpdateOptionsSchema`, `DataEngineDeleteOptionsSchema`, and `DataEngineAggregateOptionsSchema` are maintained in `@objectstack/spec` for backward compatibility but will be removed in a future major version. Migrate to the QueryAST-aligned equivalents: `EngineQueryOptionsSchema`, `EngineUpdateOptionsSchema`, `EngineDeleteOptionsSchema`, `EngineAggregateOptionsSchema`. diff --git a/content/docs/kernel/contracts/metadata-service.mdx b/content/docs/kernel/contracts/metadata-service.mdx index 40b03be06e..3dc7f924b8 100644 --- a/content/docs/kernel/contracts/metadata-service.mdx +++ b/content/docs/kernel/contracts/metadata-service.mdx @@ -61,7 +61,7 @@ export interface IMetadataService { getOverlay?(type: string, name: string, scope?: 'platform' | 'user'): Promise; saveOverlay?(overlay: MetadataOverlay): Promise; removeOverlay?(type: string, name: string, scope?: 'platform' | 'user'): Promise; - getEffective?(type: string, name: string, context?: { userId?: string; tenantId?: string; roles?: string[]; permissions?: string[] }): Promise; + getEffective?(type: string, name: string, context?: { userId?: string; tenantId?: string; positions?: string[]; permissions?: string[] }): Promise; // Watch / subscribe (optional) watch?(type: string, callback: MetadataWatchCallback): MetadataWatchHandle; diff --git a/content/docs/kernel/contracts/storage-service.mdx b/content/docs/kernel/contracts/storage-service.mdx index 2cc44932a4..be8f95bda8 100644 --- a/content/docs/kernel/contracts/storage-service.mdx +++ b/content/docs/kernel/contracts/storage-service.mdx @@ -5,7 +5,7 @@ description: Reference for the Storage Service contract — file upload, downloa # IStorageService Contract -The Storage Service provides a unified interface for **file management** — uploading, downloading, and organizing files across different storage backends (local filesystem, S3, GCS, Azure Blob). +The Storage Service provides a unified interface for **file management** — uploading, downloading, and organizing files across different storage backends (local filesystem and S3-compatible object storage). **Source:** `packages/spec/src/contracts/storage-service.ts` diff --git a/content/docs/kernel/events.mdx b/content/docs/kernel/events.mdx index e1d4f6f307..56e9d5e214 100644 --- a/content/docs/kernel/events.mdx +++ b/content/docs/kernel/events.mdx @@ -71,7 +71,7 @@ Every data hook is a single-argument handler `(ctx: HookContext) => void | Promi | :--- | :--- | | `ctx.object` | Target object name (immutable). | | `ctx.event` | Current lifecycle event, e.g. `'beforeInsert'` (immutable). | -| `ctx.input` | **Mutable** input. Shapes: insert `{ doc }`, update `{ id, doc }`, delete `{ id }`. Modify this to change the operation. | +| `ctx.input` | **Mutable** input. Shapes: insert `{ data }`, update `{ id, data }`, delete `{ id }`. Modify this to change the operation. | | `ctx.result` | Operation result, available in `after*` events (mutable). | | `ctx.previous` | Record state before the operation (update/delete). | | `ctx.session` | Auth/tenancy info (`userId`, `tenantId`, `roles`, …). | @@ -82,15 +82,14 @@ Every data hook is a single-argument handler `(ctx: HookContext) => void | Promi ```typescript // Enrich a record before it is created engine.registerHook('beforeInsert', async (ctx) => { - if (ctx.object === 'order' && ctx.input.amount > 10000) { - ctx.logger?.warn?.('Large order detected!'); - ctx.input.requires_approval = true; + if (ctx.object === 'order' && ctx.input.data.amount > 10000) { + ctx.input.data.requires_approval = true; } }, { object: 'order' }); -// React after a record is created +// React after a record is created — the created record is on ctx.result engine.registerHook('afterInsert', async (ctx) => { - await notifyWebhook(ctx.object, ctx.input.id); + await notifyWebhook(ctx.object, ctx.result?.id); }, { object: 'order' }); ``` @@ -103,7 +102,7 @@ What happens when a data hook throws is governed by the hook's **`onError`** pol ```typescript engine.registerHook('beforeInsert', async (ctx) => { - if (ctx.object === 'invoice' && !ctx.input.customer_id) { + if (ctx.object === 'invoice' && !ctx.input.data.customer_id) { throw new Error('Invoice must have a customer'); // Aborts the insert (default onError) } }); diff --git a/content/docs/kernel/runtime-services/audit-service.mdx b/content/docs/kernel/runtime-services/audit-service.mdx index 6ea8dc7cef..6b0de54ed3 100644 --- a/content/docs/kernel/runtime-services/audit-service.mdx +++ b/content/docs/kernel/runtime-services/audit-service.mdx @@ -33,4 +33,4 @@ services.audit.record(entry: { ## Typical Errors -Implementations should avoid throwing into user paths. Callers should treat audit writes as best-effort unless strict compliance mode is enabled by host code. +Sink implementations should avoid throwing into user paths: the runtime caller (for example `service-settings`) awaits `record()` without a surrounding `try`/`catch`, so a throwing sink propagates its error and fails the caller's write. diff --git a/content/docs/kernel/runtime-services/data-service.mdx b/content/docs/kernel/runtime-services/data-service.mdx index bc074e0679..93a6e8d2b5 100644 --- a/content/docs/kernel/runtime-services/data-service.mdx +++ b/content/docs/kernel/runtime-services/data-service.mdx @@ -35,7 +35,7 @@ services.data.delete(object: string, id: string): Promise ## Typical Errors - `RECORD_NOT_FOUND` -- `VALIDATION_ERROR` +- `VALIDATION_FAILED` - `PERMISSION_DENIED` ## Example diff --git a/content/docs/kernel/runtime-services/examples.mdx b/content/docs/kernel/runtime-services/examples.mdx index d87393952d..0ef96f46ad 100644 --- a/content/docs/kernel/runtime-services/examples.mdx +++ b/content/docs/kernel/runtime-services/examples.mdx @@ -30,7 +30,7 @@ export async function beforeUpdate(ctx: any) { const ok = await ctx.services?.sharing?.canEdit('contract', ctx.input.id, { userId: ctx.session?.userId, tenantId: ctx.session?.tenantId, - roles: ctx.session?.roles, + positions: ctx.session?.positions, }); if (!ok) { diff --git a/content/docs/kernel/runtime-services/index.mdx b/content/docs/kernel/runtime-services/index.mdx index 335304802d..bae7ba7bfa 100644 --- a/content/docs/kernel/runtime-services/index.mdx +++ b/content/docs/kernel/runtime-services/index.mdx @@ -8,8 +8,8 @@ description: Reference entry for runtime `services.*` APIs used by flow nodes, h **Binding note.** These pages document the stable `services.*` contract surface (signatures match the client SDK and `packages/spec/src/contracts/`). In this -repo's runtime, hook bodies reach the equivalent capabilities through `ctx.api` -(scoped data operations) and `ctx.getService(...)` (any registered service) — +repo's runtime, hook bodies reach scoped data operations through `ctx.api`, +while plugin code resolves any registered service through `ctx.getService(...)` — a literal `services.*` object is not injected into hook contexts by the open framework today. Managed runtimes provide the `services.*` binding directly. diff --git a/content/docs/kernel/runtime-services/queue-service.mdx b/content/docs/kernel/runtime-services/queue-service.mdx index 4c85f11535..dbd1d43ac1 100644 --- a/content/docs/kernel/runtime-services/queue-service.mdx +++ b/content/docs/kernel/runtime-services/queue-service.mdx @@ -23,5 +23,5 @@ services.queue.purgeFailed?(messageId: string): Promise ## Typical Errors -- `MESSAGE_NOT_FOUND` — `replay`/`purgeFailed` called with an unknown message id. +- `MESSAGE_NOT_FOUND` — `replay` called with an unknown message id. (`purgeFailed` is idempotent — an unknown id is a silent no-op.) - `INVALID_STATE` — `replay`/`purgeFailed` called on a message that is not in the `dlq` or `failed` state. diff --git a/content/docs/kernel/runtime-services/settings-service.mdx b/content/docs/kernel/runtime-services/settings-service.mdx index 22a866c27b..7ce9dc042a 100644 --- a/content/docs/kernel/runtime-services/settings-service.mdx +++ b/content/docs/kernel/runtime-services/settings-service.mdx @@ -26,6 +26,7 @@ services.settings.subscribe(namespace: string | undefined, handler: SettingsChan - `SETTINGS_LOCKED` (`SettingsLockedError`) - `SETTINGS_UNKNOWN_NAMESPACE` (`UnknownNamespaceError`) - `SETTINGS_UNKNOWN_KEY` (`UnknownKeyError`) +- `SETTINGS_VALIDATION` (`SettingsValidationError`) — thrown by `set` / `setMany` when a write would leave a visible `required` specifier empty or violate its `pattern` ## Notes diff --git a/content/docs/kernel/runtime-services/sharing-service.mdx b/content/docs/kernel/runtime-services/sharing-service.mdx index 01612dccdd..353696935a 100644 --- a/content/docs/kernel/runtime-services/sharing-service.mdx +++ b/content/docs/kernel/runtime-services/sharing-service.mdx @@ -26,8 +26,8 @@ services.sharing.listShares(object: string, recordId: string, context: SharingEx ## Typical Errors -- `PERMISSION_DENIED` -- `SHARE_NOT_FOUND` (implementation-defined) +- `FORBIDDEN` (403) — a write denied by the `canEdit` gate. Thrown by the sharing engine middleware; `canEdit` itself returns `false` rather than throwing. +- `VALIDATION_FAILED` — `grant`/`revoke` called without a required field (`object`, `recordId`, `recipientId`, or `shareId`). `revoke` is otherwise a no-op when the share id is not found. ## Example @@ -35,7 +35,7 @@ services.sharing.listShares(object: string, recordId: string, context: SharingEx const allowed = await services.sharing.canEdit('contract', ctx.input.id, { userId: ctx.session?.userId, tenantId: ctx.session?.tenantId, - roles: ctx.session?.roles, + positions: ctx.session?.positions, }); if (!allowed) throw new Error('PERMISSION_DENIED'); ``` diff --git a/content/docs/kernel/runtime-services/versioning.mdx b/content/docs/kernel/runtime-services/versioning.mdx index 344ba63fd9..e4f77192c4 100644 --- a/content/docs/kernel/runtime-services/versioning.mdx +++ b/content/docs/kernel/runtime-services/versioning.mdx @@ -15,8 +15,8 @@ description: Stability labels and how to track runtime service API breaking chan When a runtime service API changes in a breaking way: 1. Update the relevant page in this chapter. -2. Add a breaking-change note to root `CHANGELOG.md`. -3. Include migration guidance and affected methods. +2. Add a changeset entry under `.changeset/` — changesets generates each package's `CHANGELOG.md` from these entries. (The root `CHANGELOG.md` is retained for historical entries and is not hand-edited.) +3. Record migration guidance and affected methods in the per-major release page under `content/docs/releases/`. ## Current Matrix diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx index bc01fc6b2e..85fc26a31a 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -220,7 +220,7 @@ The `auth` service covers both **authentication** (identity) and **authorization | **Territory Management** | Authorization | Data territory assignment and access | P2 | | **SCIM** | Authentication | Enterprise user provisioning protocol | P2 | -### Spec Files: `identity.zod.ts`, `role.zod.ts`, `organization.zod.ts`, `auth-config.zod.ts`, `tenant.zod.ts` +### Spec Files: `identity.zod.ts`, `permission.zod.ts`, `organization.zod.ts`, `auth-config.zod.ts`, `tenant.zod.ts` --- diff --git a/content/docs/kernel/services.mdx b/content/docs/kernel/services.mdx index e786f930ac..7c47c220fb 100644 --- a/content/docs/kernel/services.mdx +++ b/content/docs/kernel/services.mdx @@ -85,7 +85,7 @@ The core ecosystem defines several standard service contracts: | Service Name | Interface | Provider Example | | :--- | :--- | :--- | -| `http-server` | `IHttpServer` | `plugin-hono-server`, `adapter-nextjs` | +| `http-server` | `IHttpServer` | `plugin-hono-server` | | `data` | `IDataEngine` | `@objectstack/objectql` (drivers implement `IDataDriver`) | | `auth` | `IAuthService` | `plugin-auth` | | `api-registry` | `ApiRegistry` | `@objectstack/core` | diff --git a/content/docs/permissions/access-recipes.mdx b/content/docs/permissions/access-recipes.mdx index 8f6828e125..924cd53b7f 100644 --- a/content/docs/permissions/access-recipes.mdx +++ b/content/docs/permissions/access-recipes.mdx @@ -62,7 +62,7 @@ Keeping capability, assignment and requirement decoupled means resources stay st ## Runnable example -- Built-in permission sets: [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts) (`member_default` shows owner-scoped RLS + tenant isolation). +- Built-in permission sets: [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts) (`member_default` shows owner-scoped RLS; tenant isolation is now the Layer 0 tenant wall in `tenant-layer.ts`, ADR-0095 D1). - Security objects: [`packages/plugins/plugin-security/src/objects`](https://github.com/objectstack-ai/framework/tree/main/packages/plugins/plugin-security/src/objects). ## Anti-patterns diff --git a/content/docs/permissions/administrator-guide.mdx b/content/docs/permissions/administrator-guide.mdx index 4cb95ab2fb..08bdf88d1f 100644 --- a/content/docs/permissions/administrator-guide.mdx +++ b/content/docs/permissions/administrator-guide.mdx @@ -22,7 +22,7 @@ The one thing to internalize before anything else: | Concept | What it is | Who authors it | You touch it | |:--|:--|:--|:--| -| **Permission set** | The only capability container — object CRUD, field security, access depth, system capabilities | Platform & app developers (in Studio) | Rarely — [Step 5](#step-5--configure-permissions-only-when-the-shipped-positions-dont-fit) | +| **Permission set** | The only capability container — object CRUD, field security, access depth, system capabilities | Platform & app developers; ships with apps | Rarely — [Step 5](#step-5--configure-permissions-only-when-the-shipped-positions-dont-fit) | | **Position** (岗位) | A flat, named job function that binds permission sets; users hold positions | Ships with apps; you assign it | **Daily** | | **Business unit** | The *one* hierarchy — org tree that decides visibility depth and delegation boundaries | You, at onboarding and reorgs | Occasionally | | **User** | The person signing in | You | Daily | @@ -144,12 +144,12 @@ report names the exact permission set and layer to fix. ## Step 5 — Configure permissions (only when the shipped positions don't fit) -One rule of thumb: **permissions are *designed* in Studio, *assigned* in -Setup.** If you are in this section, you've confirmed no shipped position -covers the need. +One rule of thumb: **capability ships with the apps you install — you rarely +author a permission set by hand.** If you are in this section, you've confirmed +no shipped position covers the need. -The permission-set **matrix editor** (Studio → Access) is a structured -spreadsheet — you never hand-write JSON: +The permission-set **matrix editor** (Setup → Access Control → Permission Sets) +is a structured spreadsheet — you never hand-write JSON: - **Object grid** — per object: Create / Read / Update / Delete, lifecycle bits (Transfer / Restore / Purge), and View All / Modify All, with per-row @@ -180,9 +180,9 @@ Full model: [Permission Sets](/docs/permissions/permission-sets). ## FAQ -**Do I need Studio?** Rarely. Setup is the administrator's home; Studio is -where permission sets are *designed* — you enter it for Step 5 and for the -Explain panel. +**Do I need Studio?** Rarely. Setup is the administrator's home — the +permission-set matrix editor (Step 5) lives there too. You enter Studio mainly +for the **Explain** panel. **A new user sees almost nothing — broken?** No: that's the baseline working. Access arrives when you assign a position (Step 3). @@ -207,7 +207,7 @@ sets can be overlaid (Step 5) or simply left unassigned. | Password policy, MFA, lockout, SSO | Setup → Configuration → Authentication | | Company info, localization, branding | Setup → Configuration | | Sessions, notification events, audit logs | Setup → Diagnostics | -| Permission matrix editor, Explain access | Studio → Access | +| Explain access | Studio → Access | --- diff --git a/content/docs/permissions/attachments-access.mdx b/content/docs/permissions/attachments-access.mdx index c23b635d79..c007dac80b 100644 --- a/content/docs/permissions/attachments-access.mdx +++ b/content/docs/permissions/attachments-access.mdx @@ -36,11 +36,13 @@ Attachments panel renders only for such objects. Any other target is rejected: This is enforced by a `beforeInsert` hook on `sys_attachment` and is independent of the record-level checks below. -## Create — read visibility (+ edit-on-parent) & provenance +## Create — parent-edit access & provenance -Creating a `sys_attachment` requires that the caller can **read** the parent -record (verified with a caller-scoped `findOne`, so RLS / OWD / sharing of the -parent object apply), and — for edit-on-parent parity — that they can edit it. +Creating a `sys_attachment` requires that the caller can **edit** the parent +record — Salesforce edit-on-parent parity — verified with the sharing service's +`canEdit`, so RLS / OWD / sharing of the parent object apply (public-model +parents are editable by any member by design). When no sharing service is +wired, the gate degrades to caller-scoped read visibility (a `findOne`). `uploaded_by` is **server-stamped** from the session; a client-supplied value is ignored. @@ -117,7 +119,7 @@ can be shared across records). Reclamation is handled by the platform LifecycleS | Operation | Requirement | Deny code | | --- | --- | --- | | Attach (create) | parent object opts in (`enable.files`) | `FILES_DISABLED` (403) | -| Attach (create) | can read + edit the parent record | `ATTACHMENT_PARENT_ACCESS` (403) | +| Attach (create) | can edit the parent record | `ATTACHMENT_PARENT_ACCESS` (403) | | List / read | inherits parent read visibility | *(filtered out)* | | Delete | uploader or parent editor (+ RBAC delete grant) | `ATTACHMENT_DELETE_DENIED` / `PERMISSION_DENIED` (403) | | Download | session + owner-or-parent-read (attachments scope) | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) | diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 0b5478d6f8..ca6757e9cf 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -570,7 +570,7 @@ new AuthPlugin({ #### Send Magic Link ```typescript -const response = await fetch('http://localhost:3000/api/v1/auth/magic-link/send', { +const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/magic-link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -780,7 +780,9 @@ const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/email', }); const data = await response.json(); -const token = data.data.token; +// better-auth returns the bearer token at the top level of a direct +// `/sign-in/email` response (the client SDK re-wraps it under `data`). +const token = data.token; // Use token in subsequent requests const protectedResponse = await fetch('http://localhost:3000/api/v1/data/task', { @@ -830,7 +832,7 @@ All endpoints are available under `/api/v1/auth/*`: #### Magic Links -- `POST /api/v1/auth/magic-link/send` - Send magic link email +- `POST /api/v1/auth/sign-in/magic-link` - Send magic link email - `GET /api/v1/auth/magic-link/verify` - Verify magic link #### Phone Number (requires `plugins.phoneNumber`) diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 7537df0a20..061e9d6996 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -383,13 +383,13 @@ The complete, prioritized gap map lives in issue **#2561** (the production | ADR | Owns | |---|---| | [0049](/adr/0049-no-unenforced-security-properties) | No unenforced security properties (enforce / mark / remove) | -| [0054](/adr/0054-prove-it-runs) | Prove-it-runs — high-risk classes need runtime proofs | +| [0054](/adr/0054-runtime-proof-for-authorable-surface) | Prove-it-runs — high-risk classes need runtime proofs | | [0056](/adr/0056-permission-model-landing-verification) | Permission-model landing: OWD, anonymous deny default, D10 matrix | | [0057](/adr/0057-erp-authorization-core-business-units-and-scope-depth) | Business units, scope depth, declarative RBAC seeding, platform-owned assignment | | [0066](/adr/0066-unified-authorization-model) | Unified model: capability registry, posture, precedence, future refinements | -| [0068](/adr/0068-identity-roles) | Built-in identity positions (formerly "identity roles"), `EvalUser` | +| [0068](/adr/0068-unified-user-context-and-built-in-identity-roles) | Built-in identity positions (formerly "identity roles"), `EvalUser` | | [0069](/adr/0069-enterprise-authentication-hardening) | Enterprise authentication hardening (phased) | -| [0078](/adr/0078-no-inert-declarable-metadata) | No inert declarable metadata | +| [0078](/adr/0078-no-silently-inert-metadata) | No inert declarable metadata | | [0086](/adr/0086-authz-metadata-config-boundary-and-cross-package-composition) | Metadata↔config boundary, package provenance, cross-package composition | | 0090 | Permission Model v2: position rename + vocabulary freeze, profile removal, fail-closed OWD default + external dial, audience anchors, principal taxonomy, publish linter, delegated administration, explain engine + access matrix | | 0091 | Grant lifecycle: validity windows + resolution-time filtering (L1, landed), delegation, break-glass, recertification substrate | diff --git a/content/docs/permissions/explain.mdx b/content/docs/permissions/explain.mdx index 57c8151852..5fadeb751d 100644 --- a/content/docs/permissions/explain.mdx +++ b/content/docs/permissions/explain.mdx @@ -41,7 +41,7 @@ another user's private note reports: "layer": "vama_bypass", "verdict": "widens", "detail": "View/Modify All Data bypass held via [showcase_auditor] — ownership and sharing checks are skipped.", - "contributors": [{ "kind": "permission_set", "name": "showcase_auditor", "via": "position:auditor" }] + "contributors": [{ "kind": "permission_set", "name": "showcase_auditor", "via": "direct grant" }] } ``` @@ -95,9 +95,10 @@ A deployment without `@objectstack/plugin-security` answers 501. - **The [access-matrix snapshot gate](/docs/permissions/access-matrix)** — the publish-time matrix is the same evaluation run over representative (permission set × object) pairs; explain is the per-decision zoom lens. -- **Delegated-administration audits** — explain reports both *who granted* - (the `granted_by` stamp) and, via contributor attribution, *who could - have* ([delegated administration](/docs/permissions/delegated-administration)). +- **Delegated-administration audits** — via contributor attribution, each + layer's verdict names the permission set behind it and how the caller holds + it — including a position held *via delegation* + ([delegated administration](/docs/permissions/delegated-administration)). ## See also diff --git a/content/docs/permissions/field-level-security.mdx b/content/docs/permissions/field-level-security.mdx index c1a6f791e6..229bed1996 100644 --- a/content/docs/permissions/field-level-security.mdx +++ b/content/docs/permissions/field-level-security.mdx @@ -54,14 +54,14 @@ names in `details.forbiddenFields`: ```json { - "error": { - "code": "PERMISSION_DENIED", - "message": "[Security] Field write denied: not permitted to edit [salary, ssn] on 'employee'", - "details": { - "operation": "insert", - "object": "employee", - "forbiddenFields": ["salary", "ssn"] - } + "name": "PermissionDeniedError", + "code": "PERMISSION_DENIED", + "statusCode": 403, + "message": "[Security] Field write denied: not permitted to edit [salary, ssn] on 'employee'", + "details": { + "operation": "insert", + "object": "employee", + "forbiddenFields": ["salary", "ssn"] } } ``` @@ -100,4 +100,3 @@ writes. - [Permissions & Identity overview](/docs/permissions) - [Permission Sets](/docs/permissions/permission-sets) -- [Permission Sets](/docs/permissions/permission-sets) diff --git a/content/docs/permissions/permission-metadata.mdx b/content/docs/permissions/permission-metadata.mdx index 438e1a9192..3497c4957d 100644 --- a/content/docs/permissions/permission-metadata.mdx +++ b/content/docs/permissions/permission-metadata.mdx @@ -60,14 +60,14 @@ const salesUserPermission = { | :--- | :--- | :--- | :--- | | `name` | `string` | ✅ | Machine name (`snake_case`) | | `label` | `string` | optional | Display label | -| `isDefault` | `boolean` | optional | [ADR-0090 D5] Install-time suggestion to bind this set to the `everyone` position (admin confirms; never auto-bound) | +| `isDefault` | `boolean` | optional | [ADR-0090 D5] Baseline for the `everyone` position: an app-level default set is auto-bound at boot (guarded, idempotent); a package-shipped set instead becomes an install-time suggestion an admin confirms | | `adminScope` | `AdminScope` | optional | [ADR-0090 D12] Delegated-administration scope (BU subtree + assignable-set allowlist) | | `objects` | `Record` | ✅ | Object-level permissions | | `fields` | `Record` | optional | Field-level security, keyed by `'object.field'` | | `systemPermissions` | `string[]` | optional | System-level capabilities | | `tabPermissions` | `Record` | optional | Tab/app visibility | | `rowLevelSecurity` | `RowLevelSecurityPolicy[]` | optional | Row-level security policies | -| `contextVariables` | `Record` | optional | RLS context variables | +| `contextVariables` | `Record` | optional | **Not implemented** — declared but ignored; the RLS engine reads only the `current_user.*` built-ins (see note below) | ## Object Permissions @@ -129,19 +129,21 @@ Control which apps/tabs are visible to users: ```typescript tabPermissions: { - crm: 'visible', // Always shown - admin: 'hidden', // Never shown - reports: 'default_on', // Shown by default, user can hide - analytics: 'default_off', // Hidden by default, user can show + crm: 'visible', // Shown + admin: 'hidden', // Hidden — removed from nav + reports: 'default_on', // Shown today (per-user toggle default not yet implemented) + analytics: 'default_off', // Shown today (per-user toggle default not yet implemented) } ``` | Visibility | Description | | :--- | :--- | -| `visible` | Always visible, user cannot hide | -| `hidden` | Always hidden, user cannot show | -| `default_on` | Visible by default, user can toggle off | -| `default_off` | Hidden by default, user can toggle on | +| `visible` | Shown (also the effective behavior when a tab is left unset) | +| `hidden` | Hidden — the app is removed from the user's navigation | +| `default_on` | Shown today; reserved for a future per-user toggle default | +| `default_off` | Shown today; reserved for a future per-user toggle default | + +**Note:** Only `hidden` currently affects runtime behavior — the app-visibility gate hides an app when its tab is set to `hidden` and treats every other value (including `default_off`) as visible. The per-user toggle / default-off semantics implied by `default_on`/`default_off` are not yet implemented. ## System Permissions @@ -173,26 +175,21 @@ rowLevelSecurity: [ using: "owner_id == current_user.id", }, { - name: 'same_department', + name: 'same_organization', object: 'account', operation: 'select', - using: "department == current_user.department", + using: "organization_id == current_user.organization_id", }, ] ``` ### Context Variables -Provide custom context values that RLS conditions can reference (in addition to -the built-in `current_user.*` variables): - -```typescript -contextVariables: { - allowed_regions: ['US', 'EU'], - access_level: 2, - custom_attribute: 'value', -} -``` +**Note:** The `contextVariables` field is **not yet implemented** — the RLS +compiler never reads it, so custom context values are **not** injected into +row-level conditions. Conditions can only reference the built-in `current_user.*` +variables described below; authoring `contextVariables` raises a build-time +liveness warning. The built-in context variables available in RLS `using`/`check` clauses include `current_user.id`, `current_user.organization_id`, and `current_user.positions` @@ -274,10 +271,10 @@ const salesManagerPermission = { rowLevelSecurity: [ { - name: 'team_accounts', + name: 'org_isolation', object: 'account', operation: 'select', - using: "team == current_user.team", + using: "organization_id == current_user.organization_id", }, ], }; diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 19aeccb01a..04aa712437 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -135,7 +135,9 @@ A package may mark one of its sets `isDefault: true`: an **install-time suggestion** to bind it to the `everyone` anchor. The admin confirms each suggestion individually; nothing auto-binds, and the D7 linter rejects an `isDefault` set that carries anchor-forbidden bits (VAMA, destructive bits, -wildcards, system permissions). +system permissions). A plain `'*'` wildcard grant is *not* an anchor-forbidden +bit for `everyone` — the platform's own `member_default` baseline is exactly +that shape; the wildcard ban is the stricter `guest` tier's rule. Pending suggestions are materialized as `sys_audience_binding_suggestion` rows (one per package × set × anchor, read-only over the data API) and diff --git a/content/docs/permissions/permissions-matrix.mdx b/content/docs/permissions/permissions-matrix.mdx index e2f1a39c9e..b48882136c 100644 --- a/content/docs/permissions/permissions-matrix.mdx +++ b/content/docs/permissions/permissions-matrix.mdx @@ -45,7 +45,7 @@ and `plugin-security/src/security-plugin.ts` (`computeLayeredRlsFilter`). -**Lifecycle operations are pending:** the `transfer` / `restore` / `purge` operations do not exist in ObjectQL yet (roadmap M2). Their RBAC gate is already mapped in the permission evaluator — the moment the operations ship they are denied unless the matching flag (or `modifyAllRecords`) is granted — but authoring these flags today grants nothing (#1883). +**Lifecycle operations are partly pending:** the dedicated `transfer` / `restore` / `purge` ObjectQL operations do not exist yet (roadmap M2); their RBAC gate is already mapped in the permission evaluator, so the moment they ship they are denied unless the matching flag (or `modifyAllRecords`) is granted. One exception: `allowTransfer` is **already enforced today** through the ordinary `insert` / `update` door — `owner_id` is system-managed, so planting a record under another user or reassigning / disowning one is denied unless the caller holds `allowTransfer` (or `modifyAllRecords`, which implies it) (#3004). Authoring `allowRestore` / `allowPurge` today still grants nothing (#1883). --- @@ -196,9 +196,11 @@ OWD sets the baseline access level for each object across the entire organizatio These are the **only four canonical values** (ADR-0090 D4). The legacy spellings -`read` / `read_write` / `full` were **removed** — the conversion pipeline rewrites -them to `public_read` / `public_read_write` on upgrade, and an unknown value -resolves to `private` (fail-closed). A custom object that omits `sharingModel` +`read` / `read_write` / `full` were **removed**. The conversion pipeline mechanically +rewrites `read` → `public_read` and `read_write` → `public_read_write` on upgrade; +`full` has no lossless target (it implied access wider than any canonical value), so it +is surfaced as a manual migration to `public_read_write` or explicit sharing rules. An +unknown value resolves to `private` (fail-closed). A custom object that omits `sharingModel` resolves to `private` at runtime (D1), and `os validate`'s security-posture check requires every custom object to declare it explicitly. These models are enforced by `plugin-sharing` + `plugin-security` and dogfood-proven over the real HTTP stack. diff --git a/content/docs/permissions/profiles.mdx b/content/docs/permissions/profiles.mdx index b0cc68d4a9..12f75b347b 100644 --- a/content/docs/permissions/profiles.mdx +++ b/content/docs/permissions/profiles.mdx @@ -27,8 +27,9 @@ never by authoring "subtraction profiles". > **Migrating v1 metadata.** Replace `isProfile: true` with either > `isDefault: true` (if the set was the app's default posture) or nothing (if -> it was a job-function set — bind it to a position instead). The D7 publish -> linter and the schema both reject the old shape. +> it was a job-function set — bind it to a position instead). `isProfile` is no +> longer part of `PermissionSetSchema`, so a leftover flag is simply ignored — +> migrate it rather than rely on it. ## See also diff --git a/content/docs/permissions/rls.mdx b/content/docs/permissions/rls.mdx index ea81d575b7..762daa292c 100644 --- a/content/docs/permissions/rls.mdx +++ b/content/docs/permissions/rls.mdx @@ -74,27 +74,41 @@ everything. Internally `find` / `findOne` / `count` / `aggregate` all map to ## The expression grammar -This is the part that bites. RLS expressions are **not** general CEL — only -four forms compile: +RLS predicates are **canonical CEL**, lowered into a query filter by the shared +pushdown compiler — the same lowering +[sharing rules](/docs/permissions/sharing-rules) use. A broad subset compiles; +anything the compiler can't lower **fails closed** (the policy matches zero rows) +rather than raising at query time. + +These all compile: | Form | Example | |:---|:---| -| Column equals a context property | `created_by == current_user.id` | +| Column equals a context value | `created_by == current_user.id` | | Column equals a literal | `status == 'active'` | -| Column is in a context array | `owner_id IN (current_user.org_user_ids)` | -| Always true | `1 = 1` | - -Everything else is rejected, including `AND` / `OR` / `NOT`, comparison -operators other than equality (`>`, `<`, `!=`), `IS NULL`, `NOT IN`, -`LIKE` / `ILIKE`, regex, `ANY` / `ALL`, subqueries, and time functions -(`NOW()`, `CURRENT_DATE`). +| Comparison (`!=`, `>`, `>=`, `<`, `<=`) | `amount > 1000` | +| Set membership | `owner_id in current_user.org_user_ids` | +| Null check | `deleted_at == null` | +| Prefix / suffix / substring | `code.startsWith('EU-')` | +| Boolean combination (`&&`, `\|\|`, `!`) | `owner_id == current_user.id && status == 'open'` | +| Always true | `1 == 1` | + +What **fails closed** (never silently dropped, per the security contract): a +subquery, a cross-object / relation hop (`account.region`), arithmetic +(`+ - * /`), and any function call — including time functions like `NOW()` / +`CURRENT_DATE`. Express anything subquery-shaped as a pre-resolved +`current_user.*` array instead (see [Context variables](#context-variables)). + +The legacy SQL-ish spellings (`=` for equality, `IN (...)`) still compile through +a transitional bridge that rewrites them to `==` / `in`, but canonical CEL is the +authoring form — the bridge logs a deprecation warning. - -**There is no `AND` / `OR` inside a policy — use multiple policies instead.** -Policies on the same object compose with **OR** (see below), which covers -"owner *or* team member". For "owner *and* active", model the second condition -in the data (a dedicated column or a narrower context array) rather than -reaching for a compound expression the compiler will reject. + +**Two ways to combine conditions.** Within one policy, use CEL `&&` / `||` — +`owner_id == current_user.id && status == 'open'` compiles. Across policies, +separate policies on the same object also compose with **OR** (see below), so +"owner *or* team member" can be one `||` predicate or two policies. Reach for +multiple policies when the alternatives apply to different positions. ## Context variables @@ -167,7 +181,7 @@ Don't guess — ask the runtime: ```bash curl -b cookies.txt \ - "https://your-app.example.com/api/v1/security/explain?object=showcase_task&operation=select&userId=usr_123" + "https://your-app.example.com/api/v1/security/explain?object=showcase_task&operation=read&userId=usr_123" ``` The response walks the layers (`tenant_isolation`, `object_crud`, `fls`, diff --git a/content/docs/permissions/sharing-rules.mdx b/content/docs/permissions/sharing-rules.mdx index 62df4c75ac..f62e6921ed 100644 --- a/content/docs/permissions/sharing-rules.mdx +++ b/content/docs/permissions/sharing-rules.mdx @@ -140,7 +140,7 @@ export const AccountTeamSharingRule = defineSharingRule({ | `group` | All members of a public group — **declared but not enforced**: skipped at seed time with a warning (ADR-0049) | | `position` | Everyone assigned that position (flat expansion — positions have no tree) | | `unit_and_subordinates` | Everyone in that **business unit and every unit beneath it** (the BU tree is the one hierarchy — ADR-0090 D3) | -| `team` | Members of a `sys_team` — teams *receive* sharing; they never carry capability (ADR-0090 D8) | +| `guest` | Public / anonymous (`guest`) principals — **declared but not enforced**: no runtime recipient mapping, skipped at seed time with a warning (ADR-0049) | A criteria `condition` must be compilable by the CEL → filter pushdown compiler. A condition the compiler cannot lower is **skipped and logged — diff --git a/content/docs/permissions/sso.mdx b/content/docs/permissions/sso.mdx index 047659e2eb..96fad62f07 100644 --- a/content/docs/permissions/sso.mdx +++ b/content/docs/permissions/sso.mdx @@ -16,7 +16,7 @@ Additional providers should be contributed by product or enterprise packages thr the `auth:configure` hook. Those packages can add better-auth `socialProviders` or OIDC/generic OAuth providers without forking `@objectstack/plugin-auth`. -The Studio login and registration pages automatically render **Continue with ...** +The Console login and registration pages automatically render **Continue with ...** buttons for every enabled provider returned by `/api/v1/auth/config`. --- @@ -270,21 +270,24 @@ Restart the server (`pnpm dev`), then: curl http://localhost:3000/api/v1/auth/config ``` -Expected response (abbreviated — `getPublicConfig()` also returns `disableSignUp`/`requireEmailVerification` on `emailPassword` and additional `features` keys such as `multiOrgEnabled`, `oidcProvider`, `deviceAuthorization`, and `admin`): +Expected response (the endpoint wraps `getPublicConfig()` in a `{ success, data }` envelope; abbreviated — `getPublicConfig()` also returns `disableSignUp`/`requireEmailVerification` on `emailPassword` and additional `features` keys such as `multiOrgEnabled`, `oidcProvider`, `deviceAuthorization`, and `admin`): ```json { - "emailPassword": { "enabled": true }, - "socialProviders": [ - { "id": "google", "name": "Google", "enabled": true, "type": "social" }, - { "id": "github", "name": "GitHub", "enabled": true, "type": "social" }, - { "id": "okta", "name": "Okta SSO", "enabled": true, "type": "oidc" } - ], - "features": { "twoFactor": false, "passkeys": false, "magicLink": false, "organization": true } + "success": true, + "data": { + "emailPassword": { "enabled": true }, + "socialProviders": [ + { "id": "google", "name": "Google", "enabled": true, "type": "social" }, + { "id": "github", "name": "GitHub", "enabled": true, "type": "social" }, + { "id": "okta", "name": "Okta SSO", "enabled": true, "type": "oidc" } + ], + "features": { "twoFactor": false, "passkeys": false, "magicLink": false, "organization": true } + } } ``` -The Studio `/login` and `/register` pages will now show a button for each enabled provider. +The Console `/login` and `/register` pages will now show a button for each enabled provider. --- @@ -307,7 +310,7 @@ The Studio `/login` and `/register` pages will now show a button for each enable ## Notes -- `OS_AUTH_SECRET` must be set to a random string in production (a long random value of 32+ characters is recommended). `AUTH_SECRET` and `BETTER_AUTH_SECRET` are accepted as deprecated legacy aliases and emit a deprecation warning. If no secret is set, the auth plugin is skipped in production. +- `OS_AUTH_SECRET` must be set to a random string in production (a long random value of 32+ characters is recommended). `AUTH_SECRET` and `BETTER_AUTH_SECRET` are accepted as legacy aliases (read silently, without a deprecation warning). If no secret is set, the auth plugin is skipped in production. - `OS_AUTH_URL` (or `OS_BASE_URL` as a fallback) must match the domain registered with each provider; it determines the base URL used to build OAuth callback URLs. - Never commit client secrets to source control — use a secrets manager in production. - The redirect URI registered with each provider must match exactly: `https:///api/v1/auth/callback/`. diff --git a/content/docs/plugins/adding-a-metadata-type.mdx b/content/docs/plugins/adding-a-metadata-type.mdx index 584c17b97e..a1dcf3037a 100644 --- a/content/docs/plugins/adding-a-metadata-type.mdx +++ b/content/docs/plugins/adding-a-metadata-type.mdx @@ -114,7 +114,7 @@ editor consumes from the registered Zod schema. plugin's `onInstall` hook (see *Plugin lifecycle*, below): ```ts - import { registerMetadataTypeSchema } from '@objectstack/spec'; + import { registerMetadataTypeSchema } from '@objectstack/spec/kernel'; registerMetadataTypeSchema('my_widget', MyWidgetSchema); ``` @@ -154,7 +154,7 @@ registry.registerEditPage('my_widget', (params) => ( `DesignerEditorWrapper` handles: -- Loading `?layers=code,overlay,effective` from the REST API +- Loading the layered `{ code, overlay, effective }` view via `?layers=true` from the REST API - Dirty tracking (`JSON.stringify` diff) - Save with destructive-change confirmation dialog - Reset overlay / Refresh / History buttons diff --git a/content/docs/plugins/anatomy.mdx b/content/docs/plugins/anatomy.mdx index 805aa5da49..0c9cd95ee4 100644 --- a/content/docs/plugins/anatomy.mdx +++ b/content/docs/plugins/anatomy.mdx @@ -85,7 +85,7 @@ ObjectStack uses `type` discrimination to optimize runtime behavior, allowing th ### 4. Driver Plugin (`driver`) * **Role:** Infrastructure Connectivity. -* **Use Cases:** SQL Adaptors (`@objectstack/driver-sql`), Storage (`driver-s3`). +* **Use Cases:** SQL Adaptors (`@objectstack/driver-sql`), Storage adapters (e.g. S3). * **Behavior:** Loaded early in the lifecycle to ensure data availability. ### 5. Server Plugin (`server`) diff --git a/content/docs/plugins/development.mdx b/content/docs/plugins/development.mdx index 6cfaffe41f..1f3f607fb8 100644 --- a/content/docs/plugins/development.mdx +++ b/content/docs/plugins/development.mdx @@ -106,14 +106,14 @@ export function createHelloPlugin(): Plugin { // Hook into the record lifecycle via the data engine. // Data hooks receive a single HookContext. The input shape is - // `{ doc }` for inserts (`{ id, doc }` for updates) — mutate - // `hookCtx.input.doc` in before* hooks to change the operation. + // `{ data }` for inserts (`{ id, data }` for updates) — mutate + // `hookCtx.input.data` in before* hooks to change the operation. const engine = ctx.getService('data'); engine.registerHook('beforeInsert', async (hookCtx: any) => { - const doc = hookCtx.input.doc; - if (doc?.first_name) { + const data = hookCtx.input.data; + if (data?.first_name) { // Auto-generate a greeting field - doc.welcome_message = greetingService.greet(doc.first_name as string); + data.welcome_message = greetingService.greet(data.first_name as string); } }, { object: 'contact' }); // the engine only runs this hook for 'contact' } @@ -196,9 +196,9 @@ describe('HelloPlugin', () => { it('should add welcome message on contact create', async () => { const { ctx, hooks } = createFakeContext(); createHelloPlugin().init(ctx); - const hookCtx = { object: 'contact', input: { doc: { first_name: 'Bob' } as Record } }; + const hookCtx = { object: 'contact', input: { data: { first_name: 'Bob' } as Record } }; await hooks['beforeInsert'](hookCtx); - expect(hookCtx.input.doc.welcome_message).toBe('Hello, Bob! Welcome to ObjectStack.'); + expect(hookCtx.input.data.welcome_message).toBe('Hello, Bob! Welcome to ObjectStack.'); }); it('should scope the hook to the contact object', () => { @@ -251,15 +251,15 @@ The kernel loads plugins in dependency order, calling each plugin's `init(ctx)` ### Permission Model -Only request the minimum permissions your plugin needs: +Declare the capabilities your plugin needs in its manifest's `permissions` block +(the structured `PluginPermissionsSchema`, ADR-0025 §3.2). Request only the minimum: -| Permission | Description | +| Grant | Description | |:---|:---| -| `object:read` | Read records from objects | -| `object:write` | Create, update, delete records | -| `object:admin` | Create/modify object schemas | -| `system:read` | Read system configuration | -| `system:admin` | Modify system configuration | +| `services` | Platform services the plugin may resolve (e.g. `object`, `http`) | +| `hooks` | Lifecycle hooks the plugin may register (e.g. `record.beforeInsert`) | +| `network` | Network hosts the plugin may reach (e.g. `api.acme.com`) | +| `fs` | Filesystem paths the plugin may access | ### Error Handling @@ -393,7 +393,7 @@ export const manifest = defineStudioPlugin({ key: 'automation', label: 'Automation', icon: 'workflow', - metadataTypes: ['flow', 'job'], // ADR-0020/0088: 'workflow' and 'trigger' are retired kinds + metadataTypes: ['flow', 'job'], // ADR-0088: 'trigger' is a retired kind ('flow' and 'job' are canonical) order: 30, }], @@ -449,6 +449,7 @@ Metadata viewers declare which modes they support: | `design` | Visual drag-and-drop designer | | `code` | Raw schema/code editor | | `data` | Live data browser | +| `history` | Version/change history view | --- diff --git a/content/docs/plugins/index.mdx b/content/docs/plugins/index.mdx index 11046f80b0..ea65d3ae3b 100644 --- a/content/docs/plugins/index.mdx +++ b/content/docs/plugins/index.mdx @@ -335,7 +335,7 @@ The plugin package declares its CLI commands location and oclif metadata: #### Step 2: Implement oclif Command Classes -Place command classes under `src/commands/` (compiled to `dist/commands/`). The file path determines the command name — `commands/marketplace/search.ts` becomes `os marketplace:search`. +Place command classes under `src/commands/` (compiled to `dist/commands/`). The file path determines the command name — `commands/marketplace/search.ts` becomes `os marketplace search`. ```typescript // src/commands/marketplace/search.ts @@ -377,7 +377,7 @@ Once installed, the new commands appear in `os --help` and can be invoked direct os --help # Use marketplace commands -os marketplace:search "crm" +os marketplace search "crm" ``` To remove a plugin command, uninstall it: @@ -390,7 +390,7 @@ os plugins uninstall @acme/plugin-marketplace ## Directory Structure Convention -ObjectStack defines a Plugin Structure Standard (OPS) for consistent project layout: +Plugins follow the **ObjectStack Plugin Standards (OPS)** for a consistent project layout: ``` plugin-my-feature/ diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index 3417c8aef0..3d3e3e54b8 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -15,7 +15,7 @@ ObjectStack is organized into **71 package manifests** across multiple categorie | **Client / DX** | 5 | `client`, `client-react`, `cli`, `create-objectstack`, `vscode-objectstack` | | **Framework adapters** | 1 | `hono` (other frameworks: build a thin adapter on `HttpDispatcher` — see below) | | **Drivers** | 4 | `driver-memory`, `driver-sql`, `driver-sqlite-wasm`, `driver-mongodb` | -| **Plugins** | 18 | `plugin-auth`, `plugin-security`, `plugin-org-scoping`, `plugin-audit`, `plugin-approvals`, `plugin-sharing`, `plugin-email`, `plugin-webhooks`, `plugin-reports`, `plugin-hono-server`, `plugin-dev`, `mcp`, trigger plugins (`trigger-api`, `trigger-record-change`, `trigger-schedule`), and knowledge/embedder plugins (`knowledge-memory`, `knowledge-ragflow`, `embedder-openai`) | +| **Plugins** | 18 | `plugin-auth`, `plugin-security`, `plugin-audit`, `plugin-approvals`, `plugin-sharing`, `plugin-email`, `plugin-webhooks`, `plugin-reports`, `plugin-hono-server`, `plugin-dev`, `plugin-pinyin-search`, `mcp`, trigger plugins (`trigger-api`, `trigger-record-change`, `trigger-schedule`), and knowledge/embedder plugins (`knowledge-memory`, `knowledge-ragflow`, `embedder-openai`) | | **Platform services** | 16 | `service-analytics`, `service-automation`, `service-cache`, `service-cluster`, `service-cluster-redis`, `service-datasource`, `service-i18n`, `service-job`, `service-knowledge`, `service-messaging`, `service-package`, `service-queue`, `service-realtime`, `service-settings`, `service-sms`, `service-storage` | @@ -309,13 +309,12 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern - **When to use**: Multi-user applications with access control requirements - **README**: [View README](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/README.md) -### @objectstack/plugin-org-scoping +### @objectstack/organizations (enterprise) -**Organization Scoping Plugin** — Multi-org (a.k.a. "soft" multi-tenant) row-level scoping built on top of `plugin-security`. +**Organization Scoping** — Multi-org (a.k.a. "soft" multi-tenant) row-level scoping. Ships as a **separate, closed-source enterprise package** — it is not part of the open framework repo. It composes with the Layer 0 tenant wall in `plugin-security` (`tenant-layer.ts`). -- **Features**: `organization_id` auto-stamp on insert, per-org seed-data replay, default-org bootstrap, orphan-row claim hook -- **When to use**: Multi-organization SaaS where every row is scoped to an `sys_organization`. Enable by setting `OS_MULTI_ORG_ENABLED=true`; registered automatically before `plugin-security` -- **README**: [View README](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-org-scoping/README.md) +- **Features**: `organization_id` auto-stamp on insert; every query is AND-composed against the tenant wall so rows never leak across organizations +- **When to use**: Multi-organization SaaS where every row is scoped to an organization. Enable by setting `OS_MULTI_ORG_ENABLED=true` and installing `@objectstack/organizations`; if the flag is set but the package is missing, the platform refuses to boot (override with `OS_ALLOW_DEGRADED_TENANCY=1`) ### @objectstack/plugin-audit @@ -353,7 +352,7 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern **Approvals Plugin** — Contributes the `approval` flow node (ADR-0019): an approval runs on the one automation engine as a durable-pause node, backed by `sys_approval_request` / `sys_approval_action`. -- **Features**: Approver resolution (user/role/team/department/manager/field/queue), `first_response` / `unanimous`, record lock, status mirror, per-node SLA escalation, audit trail +- **Features**: Approver resolution (user/org_membership_level/position/team/department/manager/field/queue), `first_response` / `unanimous`, record lock, status mirror, per-node SLA escalation, audit trail - **When to use**: Any flow that needs human sign-off (expense, quote, contract, …) — add an `approval` node and branch on `approve` / `reject` ### @objectstack/plugin-sharing @@ -413,7 +412,7 @@ The open edition ships the **Hono** adapter. Hono runs on Node.js, Bun, Deno, an npx os serve --dev ``` -### @objectstack/create-objectstack +### create-objectstack **Project Scaffolding** — Create new ObjectStack projects. @@ -425,7 +424,7 @@ npx os serve --dev npx create-objectstack my-app ``` -### @objectstack/vscode-objectstack +### objectstack-vscode **VS Code Extension** — IDE support for ObjectStack. diff --git a/content/docs/protocol/backward-compatibility.mdx b/content/docs/protocol/backward-compatibility.mdx index e7af5834ab..a7f0df6713 100644 --- a/content/docs/protocol/backward-compatibility.mdx +++ b/content/docs/protocol/backward-compatibility.mdx @@ -113,10 +113,10 @@ flowchart TD ### Linting Your Project -The CLI's `lint` command validates your metadata against the current schema and surfaces issues. Adding `--fix` prints the suggested fix for each issue (dry-run; it does not modify files): +The CLI's `lint` command checks your metadata for style and convention issues and surfaces them. Adding `--fix` prints the suggested fix for each issue (dry-run; it does not modify files): ```bash -# Validate metadata against the current schema +# Check metadata for style and convention issues os lint # Show what could be auto-fixed (dry-run) @@ -130,7 +130,7 @@ os diff --breaking-only ``` -There is no `lint --deprecations` flag — deprecation scanning lives on `os doctor --scan-deprecations`. An automated `migrate`/`codemod` command is referenced in the migration guides but is not yet available; until then, combine `os doctor --scan-deprecations`, `os diff`, and the CHANGELOG migration notes (below) to upgrade across MAJOR versions. +There is no `lint --deprecations` flag — deprecation scanning lives on `os doctor --scan-deprecations`. An automated `codemod` command is referenced in the migration guides but is not yet available; until then, combine `os doctor --scan-deprecations`, `os diff`, and the CHANGELOG migration notes (below) to upgrade across MAJOR versions. ### Migration Guides diff --git a/content/docs/protocol/diagram.mdx b/content/docs/protocol/diagram.mdx index 4a411a0135..b29a33c095 100644 --- a/content/docs/protocol/diagram.mdx +++ b/content/docs/protocol/diagram.mdx @@ -19,7 +19,7 @@ ObjectStack is composed of seven protocol layers that work together to form a co |:---|:---|:---| | **Data (ObjectQL)** | `src/data/` | Objects, Fields, Queries, Hooks, State Machines | | **UI (ObjectUI)** | `src/ui/` | Views, Apps, Dashboards, Reports, Actions | -| **System (Kernel)** | `src/system/` | Manifest, Datasources, Plugins, Kernel | +| **System (Kernel)** | `src/kernel/` | Manifest, Datasources, Plugins, Kernel | | **Automation** | `src/automation/` | Flows, Workflows, Triggers, Approvals | | **AI** | `src/ai/` | Agents, RAG Pipelines, Model Registry | | **API** | `src/api/` | REST, GraphQL, WebSocket, Realtime | @@ -208,7 +208,7 @@ sequenceDiagram participant WS as WebSocket C->>API: POST /api/v1/data/task - API->>K: create(task, data) + API->>K: insert(task, data) K->>V: Validate against ObjectSchema V-->>K: Validation Result @@ -218,13 +218,13 @@ sequenceDiagram API-->>C: Error Response end - K->>BH: beforeCreate hooks + K->>BH: beforeInsert hooks BH-->>K: Transformed data K->>D: INSERT record D-->>K: Created record - K->>AH: afterCreate hooks + K->>AH: afterInsert hooks AH-->>K: Side effects complete K->>T: Fire triggers diff --git a/content/docs/protocol/kernel/config-resolution.mdx b/content/docs/protocol/kernel/config-resolution.mdx index 0737b283b0..f3de61b9ca 100644 --- a/content/docs/protocol/kernel/config-resolution.mdx +++ b/content/docs/protocol/kernel/config-resolution.mdx @@ -382,18 +382,15 @@ features: **Use Case:** Default values defined by plugin authors. ```typescript -// @mycompany/crm plugin manifest -export default definePlugin({ - name: '@mycompany/crm', - - config: { - defaults: { - maxAccountsPerUser: 1000, - enableScoring: true, - syncInterval: 'daily', - }, - }, -}); +// @mycompany/crm plugin — default config values. +// NOTE: there is no `definePlugin()` helper. A plugin is an object +// implementing the `Plugin` interface; default settings ship via a +// settings manifest whose specifiers each declare a `default`. +export const crmConfigDefaults = { + maxAccountsPerUser: 1000, + enableScoring: true, + syncInterval: 'daily', +}; // In application code (no config set) const maxAccounts = config.get('crm.maxAccountsPerUser'); @@ -865,15 +862,13 @@ export async function someHandler({ context }) { ### 4. Provide Sensible Defaults ```typescript -// ✓ GOOD: Plugin works out-of-box with defaults -export default definePlugin({ - config: { - defaults: { - maxRetries: 3, - timeout: 30000, - }, - }, -}); +// ✓ GOOD: Plugin works out-of-box with defaults. +// (Defaults ship as `default` on the plugin's settings-manifest +// specifiers; there is no `definePlugin()` helper.) +export const myPluginDefaults = { + maxRetries: 3, + timeout: 30000, +}; ``` ### 5. Document Configuration diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx index 67ce28aed4..2b746c1941 100644 --- a/content/docs/protocol/kernel/error-handling.mdx +++ b/content/docs/protocol/kernel/error-handling.mdx @@ -45,7 +45,7 @@ API 4: HTTP 200 OK with { status: "error", ... } } title="Automated Monitoring" - description="Alert on specific error codes (e.g., RATE_LIMITED spikes = upgrade prompts working)." + description="Alert on specific error codes (e.g., rate_limit_exceeded spikes = upgrade prompts working)." /> } @@ -62,10 +62,10 @@ Every error follows this structure: { "success": false, "error": { - "code": "ERROR_CODE", + "code": "error_code", "message": "Human-readable description", "details": { /* Additional context */ }, - "request_id": "req_abc123", + "requestId": "req_abc123", "timestamp": "2024-01-16T14:30:00Z" } } @@ -76,7 +76,7 @@ Every error follows this structure: - `error.code`: Machine-readable error code (use for conditionals) - `error.message`: Human-readable message (show to users or developers) - `error.details`: Additional context (field names, constraints, etc.) -- `error.request_id`: Unique request identifier for debugging +- `error.requestId`: Unique request identifier for debugging - `error.timestamp`: When error occurred (ISO 8601) ## HTTP Status Codes @@ -90,7 +90,7 @@ ObjectStack uses standard HTTP status codes: | **403** | Forbidden | Authenticated but insufficient permissions | | **404** | Not Found | Resource doesn't exist | | **409** | Conflict | Resource already exists or version mismatch | -| **422** | Unprocessable Entity | Business logic validation failed | +| **422** | Unprocessable Entity | Semantic validation failed (e.g. metadata spec validation) | | **429** | Too Many Requests | Rate limit exceeded | | **500** | Internal Server Error | Server-side error | | **503** | Service Unavailable | Server overloaded or maintenance | @@ -101,7 +101,7 @@ ObjectStack uses standard HTTP status codes: ### Authentication & Authorization -#### `UNAUTHORIZED` +#### `unauthenticated` **HTTP Status:** 401 **Meaning:** No authentication credentials provided or invalid credentials @@ -110,7 +110,7 @@ ObjectStack uses standard HTTP status codes: { "success": false, "error": { - "code": "UNAUTHORIZED", + "code": "unauthenticated", "message": "Authentication required", "details": { "hint": "Include 'Authorization: Bearer ' header" @@ -124,7 +124,7 @@ ObjectStack uses standard HTTP status codes: - Refresh expired tokens - Re-authenticate user -#### `INVALID_TOKEN` +#### `invalid_token` **HTTP Status:** 401 **Meaning:** Token is malformed or invalid @@ -133,7 +133,7 @@ ObjectStack uses standard HTTP status codes: { "success": false, "error": { - "code": "INVALID_TOKEN", + "code": "invalid_token", "message": "JWT token is invalid", "details": { "reason": "signature_verification_failed" @@ -147,7 +147,7 @@ ObjectStack uses standard HTTP status codes: - Verify token is meant for this API (check `aud` claim) - Ensure server secret key is correct -#### `TOKEN_EXPIRED` +#### `expired_token` **HTTP Status:** 401 **Meaning:** JWT token has expired @@ -156,7 +156,7 @@ ObjectStack uses standard HTTP status codes: { "success": false, "error": { - "code": "TOKEN_EXPIRED", + "code": "expired_token", "message": "JWT token expired", "details": { "expired_at": "2024-01-16T10:00:00Z", @@ -171,7 +171,7 @@ ObjectStack uses standard HTTP status codes: - Re-authenticate user - Check token lifetime settings (typically 15-60 minutes) -#### `FORBIDDEN` +#### `permission_denied` **HTTP Status:** 403 **Meaning:** Authenticated but insufficient permissions @@ -180,7 +180,7 @@ ObjectStack uses standard HTTP status codes: { "success": false, "error": { - "code": "FORBIDDEN", + "code": "permission_denied", "message": "Insufficient permissions to access this resource", "details": { "required_permission": "account:write", @@ -197,7 +197,7 @@ ObjectStack uses standard HTTP status codes: ### Validation Errors -#### `VALIDATION_ERROR` +#### `validation_error` **HTTP Status:** 400 **Meaning:** Input validation failed (schema validation) @@ -206,7 +206,7 @@ ObjectStack uses standard HTTP status codes: { "success": false, "error": { - "code": "VALIDATION_ERROR", + "code": "validation_error", "message": "Validation failed for 2 fields", "details": { "fields": [ @@ -236,14 +236,14 @@ ObjectStack uses standard HTTP status codes: **Client-side handling:** ```javascript -if (error.code === 'VALIDATION_ERROR') { +if (error.code === 'validation_error') { error.details.fields.forEach(({ field, message }) => { showFieldError(field, message); }); } ``` -#### `REQUIRED_FIELD` +#### `missing_required_field` **HTTP Status:** 400 **Meaning:** Required field is missing @@ -252,7 +252,7 @@ if (error.code === 'VALIDATION_ERROR') { { "success": false, "error": { - "code": "REQUIRED_FIELD", + "code": "missing_required_field", "message": "Missing required field: name", "details": { "field": "name", @@ -262,7 +262,7 @@ if (error.code === 'VALIDATION_ERROR') { } ``` -#### `INVALID_TYPE` +#### `invalid_field` **HTTP Status:** 400 **Meaning:** Field value has wrong type @@ -271,7 +271,7 @@ if (error.code === 'VALIDATION_ERROR') { { "success": false, "error": { - "code": "INVALID_TYPE", + "code": "invalid_field", "message": "Field 'age' must be a number", "details": { "field": "age", @@ -285,7 +285,7 @@ if (error.code === 'VALIDATION_ERROR') { ### Resource Errors -#### `NOT_FOUND` +#### `resource_not_found` **HTTP Status:** 404 **Meaning:** Requested resource doesn't exist @@ -294,7 +294,7 @@ if (error.code === 'VALIDATION_ERROR') { { "success": false, "error": { - "code": "NOT_FOUND", + "code": "resource_not_found", "message": "Account with id 'acc_999' not found", "details": { "resource": "account", @@ -309,7 +309,7 @@ if (error.code === 'VALIDATION_ERROR') { - Check user has permission to see resource (row-level security) - Resource may have been deleted -#### `ALREADY_EXISTS` +#### `duplicate_record` **HTTP Status:** 409 **Meaning:** Resource with unique constraint already exists @@ -318,7 +318,7 @@ if (error.code === 'VALIDATION_ERROR') { { "success": false, "error": { - "code": "ALREADY_EXISTS", + "code": "duplicate_record", "message": "Account with email 'john@acme.com' already exists", "details": { "resource": "account", @@ -335,7 +335,7 @@ if (error.code === 'VALIDATION_ERROR') { - Update existing resource instead of creating new one - Use different value for unique field -#### `CONSTRAINT_VIOLATION` +#### `delete_restricted` **HTTP Status:** 409 **Meaning:** Operation violates database constraint @@ -344,7 +344,7 @@ if (error.code === 'VALIDATION_ERROR') { { "success": false, "error": { - "code": "CONSTRAINT_VIOLATION", + "code": "delete_restricted", "message": "Cannot delete account with active opportunities", "details": { "resource": "account", @@ -364,7 +364,7 @@ if (error.code === 'VALIDATION_ERROR') { ### Rate Limiting -#### `RATE_LIMITED` +#### `rate_limit_exceeded` **HTTP Status:** 429 **Meaning:** Too many requests, rate limit exceeded @@ -373,7 +373,7 @@ if (error.code === 'VALIDATION_ERROR') { { "success": false, "error": { - "code": "RATE_LIMITED", + "code": "rate_limit_exceeded", "message": "Rate limit exceeded", "details": { "limit": 1000, @@ -418,7 +418,7 @@ async function fetchWithRetry(url, options = {}, maxRetries = 3) { } ``` -#### `QUOTA_EXCEEDED` +#### `quota_exceeded` **HTTP Status:** 429 **Meaning:** Monthly/daily quota exceeded @@ -427,7 +427,7 @@ async function fetchWithRetry(url, options = {}, maxRetries = 3) { { "success": false, "error": { - "code": "QUOTA_EXCEEDED", + "code": "quota_exceeded", "message": "Monthly API quota exceeded", "details": { "quota": 10000, @@ -445,58 +445,9 @@ async function fetchWithRetry(url, options = {}, maxRetries = 3) { - Upgrade to higher plan - Optimize API usage -### Business Logic Errors - -#### `BUSINESS_RULE_VIOLATION` -**HTTP Status:** 422 -**Meaning:** Operation violates business logic rule - -**Example:** -```json -{ - "success": false, - "error": { - "code": "BUSINESS_RULE_VIOLATION", - "message": "Cannot close opportunity without selecting a stage reason", - "details": { - "rule": "opportunity_close_validation", - "field": "stage_reason", - "required_when": "stage === 'Closed Won' || stage === 'Closed Lost'" - } - } -} -``` - -**How to fix:** -- Check validation rules in object schema -- Provide required fields -- Follow business process workflow - -#### `WORKFLOW_ERROR` -**HTTP Status:** 422 -**Meaning:** Workflow/automation failed - -**Example:** -```json -{ - "success": false, - "error": { - "code": "WORKFLOW_ERROR", - "message": "Approval workflow rejected the request", - "details": { - "workflow": "purchase_order_approval", - "step": "manager_approval", - "reason": "Amount exceeds manager approval limit", - "limit": 10000, - "requested": 15000 - } - } -} -``` - ### Server Errors -#### `SERVER_ERROR` +#### `internal_error` **HTTP Status:** 500 **Meaning:** Internal server error @@ -505,10 +456,10 @@ async function fetchWithRetry(url, options = {}, maxRetries = 3) { { "success": false, "error": { - "code": "SERVER_ERROR", + "code": "internal_error", "message": "An internal error occurred", "details": { - "request_id": "req_abc123", + "requestId": "req_abc123", "support_url": "https://support.acme.com/request/req_abc123" } } @@ -520,9 +471,9 @@ async function fetchWithRetry(url, options = {}, maxRetries = 3) { **How to fix:** - Retry request (may be transient) - Check server status page -- Contact support with `request_id` +- Contact support with `requestId` -#### `SERVICE_UNAVAILABLE` +#### `service_unavailable` **HTTP Status:** 503 **Meaning:** Server temporarily unavailable @@ -531,7 +482,7 @@ async function fetchWithRetry(url, options = {}, maxRetries = 3) { { "success": false, "error": { - "code": "SERVICE_UNAVAILABLE", + "code": "service_unavailable", "message": "Service temporarily unavailable", "details": { "reason": "database_maintenance", @@ -548,73 +499,6 @@ HTTP/1.1 503 Service Unavailable Retry-After: 300 ``` -### WebSocket-Specific Errors - -#### `CONNECTION_LIMIT_EXCEEDED` -**Meaning:** Too many concurrent WebSocket connections - -**Example:** -```json -{ - "type": "error", - "error": { - "code": "CONNECTION_LIMIT_EXCEEDED", - "message": "Maximum 5 concurrent connections per user", - "details": { - "limit": 5, - "current": 5 - } - } -} -``` - -**How to fix:** -- Close unused connections -- Implement connection pooling -- Use fewer browser tabs - -#### `SUBSCRIPTION_LIMIT_EXCEEDED` -**Meaning:** Too many subscriptions per connection - -**Example:** -```json -{ - "type": "error", - "subscription_id": "sub_99", - "error": { - "code": "SUBSCRIPTION_LIMIT_EXCEEDED", - "message": "Maximum 50 subscriptions per connection", - "details": { - "limit": 50, - "current": 50 - } - } -} -``` - -**How to fix:** -- Unsubscribe from unused subscriptions -- Use broader filters instead of many narrow subscriptions -- Combine related subscriptions - -#### `INVALID_MESSAGE` -**Meaning:** WebSocket message is malformed - -**Example:** -```json -{ - "type": "error", - "error": { - "code": "INVALID_MESSAGE", - "message": "Invalid JSON in WebSocket message", - "details": { - "reason": "unexpected_token", - "position": 42 - } - } -} -``` - ## Error Response Examples ### Validation Error (Multiple Fields) @@ -639,7 +523,7 @@ Content-Type: application/json { "success": false, "error": { - "code": "VALIDATION_ERROR", + "code": "validation_error", "message": "Validation failed for 3 fields", "details": { "fields": [ @@ -664,7 +548,7 @@ Content-Type: application/json } ] }, - "request_id": "req_abc123", + "requestId": "req_abc123", "timestamp": "2024-01-16T14:30:00Z" } } @@ -686,7 +570,7 @@ Content-Type: application/json { "success": false, "error": { - "code": "FORBIDDEN", + "code": "permission_denied", "message": "Insufficient permissions to delete accounts", "details": { "resource": "account", @@ -695,7 +579,7 @@ Content-Type: application/json "user_permissions": ["account:read", "account:write"], "hint": "Contact your administrator to request delete permission" }, - "request_id": "req_def456", + "requestId": "req_def456", "timestamp": "2024-01-16T14:35:00Z" } } @@ -721,7 +605,7 @@ Content-Type: application/json { "success": false, "error": { - "code": "RATE_LIMITED", + "code": "rate_limit_exceeded", "message": "Rate limit exceeded: 1000 requests per minute", "details": { "limit": 1000, @@ -730,7 +614,7 @@ Content-Type: application/json "quota_reset": "2024-01-16T14:31:00Z", "upgrade_url": "https://app.acme.com/billing/upgrade" }, - "request_id": "req_ghi789", + "requestId": "req_ghi789", "timestamp": "2024-01-16T14:30:15Z" } } @@ -749,7 +633,7 @@ if (error.message.includes('not found')) { **Good:** ```javascript -if (error.code === 'NOT_FOUND') { +if (error.code === 'resource_not_found') { // Reliable - code never changes } ``` @@ -758,15 +642,15 @@ if (error.code === 'NOT_FOUND') { **Bad:** ```javascript -alert(error.message); // "VALIDATION_ERROR: Field 'email' constraint 'format' failed" +alert(error.message); // "validation_error: Field 'email' constraint 'format' failed" ``` **Good:** ```javascript const userMessages = { - 'VALIDATION_ERROR': 'Please check your input and try again', - 'UNAUTHORIZED': 'Please log in to continue', - 'RATE_LIMITED': 'Too many requests. Please wait a moment.', + 'validation_error': 'Please check your input and try again', + 'unauthenticated': 'Please log in to continue', + 'rate_limit_exceeded': 'Too many requests. Please wait a moment.', }; showToast(userMessages[error.code] || 'An error occurred'); @@ -781,7 +665,7 @@ async function handleSubmit(data) { const response = await api.createAccount(data); return response.data; } catch (error) { - if (error.code === 'VALIDATION_ERROR') { + if (error.code === 'validation_error') { // Show errors next to fields error.details.fields.forEach(({ field, message }) => { setFieldError(field, message); @@ -809,11 +693,11 @@ async function fetchWithRetry(url, options = {}, maxRetries = 3) { if (!response.ok) { // Check if error is retryable - if (data.error.code === 'RATE_LIMITED') { + if (data.error.code === 'rate_limit_exceeded') { const retryAfter = data.error.details.retry_after || 1; await sleep(retryAfter * 1000); continue; - } else if (data.error.code === 'SERVER_ERROR') { + } else if (data.error.code === 'internal_error') { // Exponential backoff await sleep(Math.pow(2, attempt) * 1000); continue; @@ -841,7 +725,7 @@ try { await api.createAccount(data); } catch (error) { console.error('Account creation failed', { - request_id: error.request_id, + requestId: error.requestId, code: error.code, message: error.message, timestamp: error.timestamp @@ -849,7 +733,7 @@ try { // Send to error tracking service Sentry.captureException(error, { - extra: { request_id: error.request_id } + extra: { requestId: error.requestId } }); } ``` @@ -884,7 +768,7 @@ try { ## Debugging with Request IDs -Every API response includes a `request_id` for debugging: +Every API response includes a `requestId` for debugging: **Request:** ```http @@ -900,7 +784,7 @@ X-Request-ID: my-custom-id-123 { "success": true, "data": { ... }, - "request_id": "my-custom-id-123" + "requestId": "my-custom-id-123" } ``` @@ -928,10 +812,10 @@ Track error rates by code: // Metrics dashboard { "error_rates": { - "VALIDATION_ERROR": 0.05, // 5% of requests - "UNAUTHORIZED": 0.02, // 2% of requests - "RATE_LIMITED": 0.01, // 1% of requests - "SERVER_ERROR": 0.0001 // 0.01% of requests (🚨 alert if > 0.01%) + "validation_error": 0.05, // 5% of requests + "unauthenticated": 0.02, // 2% of requests + "rate_limit_exceeded": 0.01, // 1% of requests + "internal_error": 0.0001 // 0.01% of requests (🚨 alert if > 0.01%) } } ``` @@ -939,14 +823,14 @@ Track error rates by code: ### Alert Conditions **Critical alerts:** -- `SERVER_ERROR` rate > 0.1% -- `SERVICE_UNAVAILABLE` > 0 +- `internal_error` rate > 0.1% +- `service_unavailable` > 0 - Database connection failures **Warning alerts:** -- `RATE_LIMITED` spike (may indicate DDoS or integration bug) -- `UNAUTHORIZED` spike (credential leakage?) -- `VALIDATION_ERROR` spike on new form (bad client-side validation) +- `rate_limit_exceeded` spike (may indicate DDoS or integration bug) +- `unauthenticated` spike (credential leakage?) +- `validation_error` spike on new form (bad client-side validation) ### Error Budgets @@ -972,7 +856,7 @@ error_budget: ```json { "error": { - "code": "UNAUTHORIZED", + "code": "unauthenticated", "message": "Password incorrect for user john@acme.com", "details": { "attempted_password": "Password123!", // 🚨 NEVER DO THIS @@ -986,7 +870,7 @@ error_budget: ```json { "error": { - "code": "UNAUTHORIZED", + "code": "unauthenticated", "message": "Invalid credentials", "details": null } @@ -1000,7 +884,7 @@ error_budget: // Attacker probes: DELETE /api/v1/data/account/acc_123 { "error": { - "code": "FORBIDDEN", + "code": "permission_denied", "message": "You don't have permission to delete this account" } } @@ -1009,10 +893,10 @@ error_budget: **Good:** ```json -// Return NOT_FOUND for both "doesn't exist" and "exists but no permission" +// Return resource_not_found for both "doesn't exist" and "exists but no permission" { "error": { - "code": "NOT_FOUND", + "code": "resource_not_found", "message": "Account not found" } } @@ -1026,7 +910,7 @@ Even error responses can be abused: // Attacker tries to enumerate user emails for (let i = 0; i < 1000000; i++) { await register({ email: `user${i}@example.com` }); - // Response: "ALREADY_EXISTS" or "VALIDATION_ERROR" + // Response: "duplicate_record" or "validation_error" } ``` @@ -1034,7 +918,7 @@ for (let i = 0; i < 1000000; i++) { ```json { "error": { - "code": "RATE_LIMITED", + "code": "rate_limit_exceeded", "message": "Too many failed registration attempts" } } diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 999ed1579a..475323a59d 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -29,7 +29,8 @@ GET /.well-known/objectstack HTTP/1.1 Host: api.acme.com ``` -`/.well-known/objectstack` redirects to the canonical discovery route: +`/.well-known/objectstack` and the versioned `/api/v1/discovery` route both return the +discovery document directly — there is no HTTP redirect between them: ```http GET /api/v1/discovery HTTP/1.1 ``` @@ -49,16 +50,12 @@ GET /api/v1/discovery HTTP/1.1 "storage": "/api/v1/storage", "graphql": "/api/v1/graphql" }, - "features": { - "graphql": true, - "websockets": false, - "search": true, - "files": true, - "analytics": true, - "ai": false, - "workflow": true, - "notifications": true, - "i18n": true + "services": { + "data": { "enabled": true, "status": "available", "route": "/api/v1/data", "provider": "objectql" }, + "metadata": { "enabled": true, "status": "available", "route": "/api/v1/meta", "provider": "objectql" }, + "auth": { "enabled": true, "status": "available", "route": "/api/v1/auth", "provider": "plugin-auth" }, + "workflow": { "enabled": false, "status": "unavailable", "message": "Install plugin-workflow to enable" }, + "ai": { "enabled": false, "status": "unavailable", "message": "Install plugin-ai to enable" } }, "locale": { "default": "en-US", @@ -71,7 +68,7 @@ GET /api/v1/discovery HTTP/1.1 **Why discovery matters:** - **Environment agnostic:** Works across dev, staging, production without hardcoding URLs - **Version tolerance:** API routes can change without breaking clients -- **Feature detection:** Clients enable/disable features based on server capabilities +- **Feature detection:** Clients enable/disable features by inspecting each entry's `enabled` / `status` in the `services` map - **Automatic configuration:** SDKs auto-configure from discovery response ## Standard Data API @@ -147,32 +144,29 @@ Authorization: Bearer **Success Response:** -List responses are wrapped in the standard `{ success, data }` envelope. For -queries, `data` is a `FindDataResponse` containing `object`, `records`, and the -optional `total` / `hasMore` pagination hints. +A list query returns a `FindDataResponse` directly — there is no outer envelope. +The response carries the `object` name, the `records` array, and the optional +`total` / `hasMore` pagination hints. ```json { - "success": true, - "data": { - "object": "task", - "records": [ - { - "id": "task_456", - "title": "Implement login page", - "status": "in_progress", - "created_at": "2024-01-15T10:30:00Z" - }, - { - "id": "task_789", - "title": "Fix navigation bug", - "status": "todo", - "created_at": "2024-01-14T16:20:00Z" - } - ], - "total": 47, - "hasMore": true - } + "object": "task", + "records": [ + { + "id": "task_456", + "title": "Implement login page", + "status": "in_progress", + "created_at": "2024-01-15T10:30:00Z" + }, + { + "id": "task_789", + "title": "Fix navigation bug", + "status": "todo", + "created_at": "2024-01-14T16:20:00Z" + } + ], + "total": 47, + "hasMore": true } ``` @@ -286,13 +280,10 @@ record count and a `hasMore` flag: ```json { - "success": true, - "data": { - "object": "account", - "records": [], - "total": 247, - "hasMore": true - } + "object": "account", + "records": [], + "total": 247, + "hasMore": true } ``` @@ -311,18 +302,15 @@ GET /api/data/account?select=id,name,industry,revenue **Response:** ```json { - "success": true, - "data": { - "object": "account", - "records": [ - { - "id": "acc_123", - "name": "Acme Corp", - "industry": "Technology", - "revenue": 5000000 - } - ] - } + "object": "account", + "records": [ + { + "id": "acc_123", + "name": "Acme Corp", + "industry": "Technology", + "revenue": 5000000 + } + ] } ``` @@ -345,28 +333,25 @@ GET /api/data/task?expand=assignee,project **Response:** ```json { - "success": true, - "data": { - "object": "task", - "records": [ - { - "id": "task_123", - "title": "Implement API", - "assignee_id": "user_456", - "project_id": "proj_789", - "assignee": { - "id": "user_456", - "name": "John Doe", - "email": "john@acme.com" - }, - "project": { - "id": "proj_789", - "name": "CRM Rebuild", - "status": "active" - } + "object": "task", + "records": [ + { + "id": "task_123", + "title": "Implement API", + "assignee_id": "user_456", + "project_id": "proj_789", + "assignee": { + "id": "user_456", + "name": "John Doe", + "email": "john@acme.com" + }, + "project": { + "id": "proj_789", + "name": "CRM Rebuild", + "status": "active" } - ] - } + } + ] } ``` @@ -394,10 +379,15 @@ Authorization: Bearer ``` **Success Response (HTTP 200):** + +A single-record read returns a `GetDataResponse`: the `object` name, the record +`id`, and the full record under `record`. + ```json { - "success": true, - "data": { + "object": "account", + "id": "acc_123", + "record": { "id": "acc_123", "name": "Acme Corporation", "industry": "Technology", @@ -446,10 +436,15 @@ Content-Type: application/json ``` **Success Response (HTTP 201):** + +Create returns a `CreateDataResponse`: the `object` name, the new record `id`, and +the created record (including server-generated fields) under `record`. + ```json { - "success": true, - "data": { + "object": "account", + "id": "acc_124", + "record": { "id": "acc_124", "name": "TechStart Inc", "industry": "SaaS", @@ -505,10 +500,15 @@ Content-Type: application/json ``` **Success Response (HTTP 200):** + +Update returns an `UpdateDataResponse`: the `object` name, the record `id`, and the +updated record under `record`. + ```json { - "success": true, - "data": { + "object": "account", + "id": "acc_123", + "record": { "id": "acc_123", "name": "Acme Corporation", "industry": "Technology", @@ -524,20 +524,14 @@ Content-Type: application/json **Note:** Only fields included in the request body are updated. Other fields remain unchanged. **Read-only fields:** -Attempting to update read-only fields (e.g., `id`, `created_at`) returns HTTP 400: +Caller-supplied writes to statically read-only fields (e.g., `id`, `created_at`) are +**silently stripped** from a non-system update rather than rejected (#2948): the +request succeeds with HTTP 200 and every other field is applied, but the read-only +field is left unchanged. -```json -{ - "error": "Cannot update read-only fields", - "code": "VALIDATION_FAILED", - "fields": [ - { - "field": "created_at", - "message": "Field is read-only" - } - ] -} -``` +> **Note:** This differs from field-level security. A write to a field the caller +> lacks edit permission on is *rejected* with `403 PermissionDeniedError`, not +> stripped. ### Delete Record @@ -555,31 +549,25 @@ Authorization: Bearer ``` **Success Response (HTTP 200):** -```json -{ - "success": true, - "data": { - "id": "acc_123", - "deleted": true - } -} -``` -**Soft Delete (if enabled):** -If object has `enable.trash` enabled, record is marked deleted but not removed: +Delete returns a `DeleteDataResponse`: the `object` name, the record `id`, and a +`success` flag. ```json { - "success": true, - "data": { - "id": "acc_123", - "deleted": true, - "deleted_at": "2024-01-16T12:00:00Z", - "deleted_by": "user_456" - } + "object": "account", + "id": "acc_123", + "success": true } ``` + +`enable.trash` exists as an object flag, but there is currently **no soft-delete / +recycle-bin runtime** (ADR-0049): `DELETE` performs a hard delete and returns the +`{ object, id, success }` shape above. Records are removed permanently — there are no +`deleted_at` / `deleted_by` fields and no restore semantics. + + **Constraint Violations:** Database constraint failures are surfaced as structured errors. For example, a unique-constraint violation returns HTTP 409: @@ -688,10 +676,14 @@ lists items of that type. Types are singular (`object`, `view`, `app`, …), so objects are listed at `/api/v1/meta/object`. **Response:** + +A type listing returns `{ type, items }` — the requested metadata `type` plus the +`items` array of matching entries. + ```json { - "success": true, - "data": [ + "type": "object", + "items": [ { "name": "account", "label": "Account", @@ -720,10 +712,15 @@ Authorization: Bearer ``` **Response:** + +A single-item read returns `{ type, name, item }` — the metadata `type`, the item +`name`, and the full schema under `item`. + ```json { - "success": true, - "data": { + "type": "object", + "name": "account", + "item": { "name": "account", "label": "Account", "plural_label": "Accounts", @@ -888,8 +885,9 @@ GET /api/data/account?select=id,name,status ### Use Expand for Relations ❌ **Bad:** N+1 queries ```javascript -const tasks = await fetch('/api/data/task'); -for (const task of tasks.data) { +const res = await fetch('/api/data/task'); +const { records } = await res.json(); +for (const task of records) { task.assignee = await fetch(`/api/data/user/${task.assignee_id}`); } ``` diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index ee4ef35f52..f551da7357 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -69,6 +69,15 @@ i18n.t('messages.welcome', 'en', { name: 'John' }); // Translation + interpolati ObjectStack automatically determines the user's locale using a **fallback chain**: + +**Partial implementation.** The server resolves the *request* locale from the +`Accept-Language` header (highest-priority entry), then a `?locale=` query +parameter, then the i18n service's default locale — see `extractLocale` in +`packages/rest/src/rest-server.ts`. The DB-persisted **user preference**, +**tenant default**, and **IP geolocation** steps shown below are design intent: +there is no geolocation or tenant-locale resolution in the runtime today. + + ``` ┌─────────────────────────────────────────────────────────────┐ │ 1. USER PREFERENCE │ @@ -618,7 +627,10 @@ export const Account = ObjectSchema.create({ industry: Field.select({ label: 'account.fields.industry', - options: 'account.industries', // Translation key for options array + options: [ + { value: 'technology', label: 'account.industries.technology' }, + { value: 'finance', label: 'account.industries.finance' }, + ], }), }, }); @@ -633,10 +645,10 @@ export const Account = ObjectSchema.create({ "name": "Account Name", "industry": "Industry" }, - "industries": [ - { "value": "technology", "label": "Technology" }, - { "value": "finance", "label": "Finance" } - ] + "industries": { + "technology": "Technology", + "finance": "Finance" + } } ``` @@ -651,25 +663,16 @@ locale. ObjectUI automatically translates labels, placeholders, and error messages: ```typescript -// View definition +// View definition — `defineView` takes a container of named views +// ({ list, form, listViews, formViews }); a flat { name, type, ... } +// object is rejected at build time. export default defineView({ - name: 'account_list', - object: 'account', - type: 'list', - - layout: { - title: 'account.list.title', // Translation key + list: { + type: 'grid', + label: 'account.list.title', // Translation key (i18n label) + data: { provider: 'object', object: 'account' }, columns: [ - { - field: 'name', - // Label auto-translated from ObjectQL field definition - }, - ], - actions: [ - { - type: 'create', - label: 'account.actions.create', // Translation key - }, + 'name', // Column label auto-translated from the ObjectQL field definition ], }, }); @@ -764,6 +767,13 @@ Output: ### Translation Service Integration + +**Design intent — not yet implemented.** `i18n.translationService` is **not** a +recognized key on `TranslationConfigSchema` +(`packages/spec/src/system/translation.zod.ts`), and no auto-translate provider +integration ships today. The snippet below describes planned behaviour. + + ObjectStack integrates with professional translation services: ```typescript diff --git a/content/docs/protocol/kernel/index.mdx b/content/docs/protocol/kernel/index.mdx index 58cc855045..df82cdfdda 100644 --- a/content/docs/protocol/kernel/index.mdx +++ b/content/docs/protocol/kernel/index.mdx @@ -151,8 +151,8 @@ os package publish dist/objectstack.json --env --install **Kernel Approach:** ```yaml -# plugin.manifest.yml -name: @mycompany/billing +# manifest block in objectstack.config.ts (via defineStack) +id: com.mycompany.billing version: 2.0.0 dependencies: '@objectstack/core': '^2.0.0' @@ -212,7 +212,7 @@ const config = context.config.resolve('stripe.apiKey', { **Challenge:** Build a plugin marketplace like Salesforce AppExchange where third-party developers sell integrations. **Kernel Solution:** -- **Plugin Manifest:** Standardized `plugin.manifest.yml` declares what plugin provides and needs +- **Package Manifest:** The `manifest` block in `objectstack.config.ts` (compiled to `objectstack.json`) declares what a package provides and needs - **Dependency Resolution:** Automatic validation that plugin versions are compatible with core platform - **Lifecycle Hooks:** `onInstall`, `onEnable`, `onDisable` hooks for setup/teardown - **Sandboxing:** Plugins can't access each other's data or crash each other @@ -277,7 +277,7 @@ hooks in order, and exposes the resulting services through DI. ### 2. Request Handling ```typescript -// Incoming API request: GET /api/accounts/123 +// Incoming API request: GET /api/v1/data/account/123 async function handleRequest(req) { // Kernel provides context const context = await Kernel.createContext({ @@ -354,17 +354,19 @@ permissions.grant('admin', 'accounts', 'read'); **Good (Declarative):** ```yaml -# plugin.manifest.yml +# objectstack.config.ts — declared via defineStack({ ... }) objects: - name: account fields: - name: name type: text +# PermissionSet grants use allow* flags (not role/object/access) permissions: - - role: admin - object: account - access: read -# Kernel ensures runtime matches manifest + - name: account_read + objects: + account: + allowRead: true +# Kernel ensures runtime matches the declared metadata ``` ### Idempotent Operations @@ -375,8 +377,8 @@ Run `os package publish` 100 times → Same result. ### Version Control Everything All system configuration lives in Git: -- Plugin manifests: `plugin.manifest.yml` -- Configuration files: `objectstack.config.yml` +- Stack definition & manifest: `objectstack.config.ts` (via `defineStack`) +- Compiled artifact: `dist/objectstack.json` - Translation bundles: `i18n/en.json`, `i18n/de.json` **Business Value:** Rollback a bad deployment by reverting Git commit. No database state to recover, no manual cleanup. @@ -385,18 +387,15 @@ All system configuration lives in Git: ### Plugin Development ```bash -# Scaffold new plugin +# Scaffold new plugin (created under packages/plugins/plugin-/) os create plugin slack-integration # Generated structure: -slack-integration/ - plugin.manifest.yml # Metadata, dependencies, version +packages/plugins/plugin-slack-integration/ + package.json # name, version, dependencies (@objectstack/spec, zod) + tsconfig.json src/ - objects/ # ObjectQL schemas - views/ # ObjectUI layouts - actions/ # Business logic - i18n/ # Translations - tests/ + index.ts # default-export Plugin object (name, version, initialize, destroy) README.md ``` diff --git a/content/docs/protocol/kernel/lifecycle.mdx b/content/docs/protocol/kernel/lifecycle.mdx index db78d364f1..09f26f08ba 100644 --- a/content/docs/protocol/kernel/lifecycle.mdx +++ b/content/docs/protocol/kernel/lifecycle.mdx @@ -153,8 +153,8 @@ await kernel.bootstrap(); ``` **Resolution Strategy:** -1. **failOnPluginError: true** (default): Boot fails, process exits with code 1 -2. **failOnPluginError: false**: Boot continues, failed plugin is disabled and logged +1. **rollbackOnFailure: true** (default): Boot fails, already-started plugins roll back, process exits with code 1 +2. **rollbackOnFailure: false**: Boot continues, the failed plugin is skipped and logged ## Plugin Installation @@ -275,61 +275,45 @@ async function resolveDependencies( Plugins can define hooks that run at specific lifecycle events: ```typescript -// plugin.manifest.ts -export default definePlugin({ +// plugin.ts — a plugin is a plain default-exported object (there is no +// `definePlugin` helper). Lifecycle hooks are top-level methods, each +// receiving a single PluginContext ({ ql, os, logger, config, pluginId }). +export default { name: '@vendor/salesforce', version: '3.2.1', - - lifecycle: { - // Runs before installation begins - preInstall: async ({ context, transaction }) => { - // Validate environment - if (!context.config.get('salesforce.apiKey')) { - throw new Error('Salesforce API key not configured'); - } - }, - - // Runs after installation completes - postInstall: async ({ context, transaction }) => { - // Initialize default data - await context.db.insert('salesforce_settings', { - syncInterval: 3600, // 1 hour - enabled: true, - }, { transaction }); - - // Schedule sync job - await context.scheduler.create({ - name: 'salesforce-sync', - schedule: '0 * * * *', // Every hour - handler: 'salesforce.sync', - }, { transaction }); - }, - - // Runs when plugin is enabled - onEnable: async ({ context }) => { - logger.info('Salesforce sync enabled'); - await context.eventBus.publish('salesforce.enabled'); - }, - - // Runs when plugin is disabled - onDisable: async ({ context }) => { - logger.info('Salesforce sync disabled'); - await context.scheduler.pause('salesforce-sync'); - }, - - // Runs before uninstallation - preUninstall: async ({ context, transaction }) => { - // Clean up jobs - await context.scheduler.delete('salesforce-sync', { transaction }); - }, - - // Runs after uninstallation - postUninstall: async ({ context, transaction }) => { - // Optional: Remove plugin data - await context.db.delete('salesforce_settings', {}, { transaction }); - }, + + // Runs once, when the plugin is installed + onInstall: async (context) => { + // Validate environment. `config` is a plain record, not a store. + if (!context.config['salesforce.apiKey']) { + throw new Error('Salesforce API key not configured'); + } + context.logger.info('Salesforce plugin installed'); }, -}); + + // Runs when the plugin is enabled (at startup or manually) + onEnable: async (context) => { + context.logger.info('Salesforce sync enabled'); + }, + + // Runs when the plugin is disabled (at shutdown or manually) + onDisable: async (context) => { + context.logger.info('Salesforce sync disabled'); + }, + + // Runs once, when the plugin is uninstalled + onUninstall: async (context) => { + context.logger.info('Removing Salesforce plugin data'); + }, + + // Runs when the plugin version changes. Receives an UpgradeContext with + // previousVersion, newVersion, and isMajorUpgrade. + onUpgrade: async (context) => { + context.logger.info( + `Salesforce ${context.previousVersion} → ${context.newVersion}`, + ); + }, +}; ``` ### Installation CLI @@ -505,10 +489,13 @@ export async function down(db: any): Promise { } ``` -Run migrations through your existing Knex / driver tooling — ObjectStack does not -ship its own migration runner. The intent of `os generate migration` is to give -you a hand-off file you can commit, review, and execute via the database tools -your team already uses. +Run the generated files through your existing Knex / driver tooling — ObjectStack +does not execute these generated migration files for you. (To apply +metadata-driven schema changes directly, without generating files, ObjectStack +does ship first-party `os migrate plan` / `os migrate apply` commands that +reconcile the physical database to your object definitions.) The intent of +`os generate migration` is to give you a hand-off file you can commit, review, +and execute via the database tools your team already uses. #### Schema safety @@ -584,18 +571,22 @@ ObjectStack includes **built-in health monitoring** to validate system state. ### Health Check Endpoints ``` -GET /health/live - → 200 if process is alive - → 503 if process is dead/hung - -GET /health/ready - → 200 if ready to accept requests - → 503 if still booting or unhealthy +GET /health + → 200 with { status, version, uptime } if the process is alive (liveness) -GET /health/status - → Detailed health report (JSON) +GET /ready + → 200 if the kernel is running and ready to accept requests (readiness) + → 503 while still booting or shutting down ``` + +`GET /health` returns a compact liveness body (`status`, `version`, `uptime`) and +`GET /ready` returns readiness. The richer per-subsystem report and the +plugin-declared custom checks below describe the internal health-monitor model +(`PluginHealthMonitor`) — they are **not yet** exposed as a dedicated HTTP +endpoint or as a declarative plugin field. + + ### Health Status Response ```json @@ -639,10 +630,10 @@ GET /health/status Plugins can register custom health checks: ```typescript -// Plugin registers health check -export default definePlugin({ +// Plugin registers a custom health check (internal health-monitor model) +export default { name: '@vendor/salesforce', - + healthChecks: { salesforce_connection: async ({ context }) => { try { @@ -662,7 +653,7 @@ export default definePlugin({ } }, }, -}); +}; ``` ## Shutdown Sequence @@ -761,7 +752,7 @@ Don't assume success. Monitor health checks for 5-10 minutes after upgrade. ```bash # Automated monitoring objectstack upgrade @vendor/salesforce --monitor --duration 300 -# Watches /health/status for 5 minutes, auto-rollback if unhealthy +# Watches /health for 5 minutes, auto-rollback if unhealthy ``` ### 5. Document Breaking Changes diff --git a/content/docs/protocol/kernel/metadata-service.mdx b/content/docs/protocol/kernel/metadata-service.mdx index 61b2aef529..f8cf93c5be 100644 --- a/content/docs/protocol/kernel/metadata-service.mdx +++ b/content/docs/protocol/kernel/metadata-service.mdx @@ -64,7 +64,7 @@ Loaders adapt different storage backends to a common interface. | **FilesystemLoader** | `filesystem` | Reads/Writes `.json`, `.yaml`, `.ts` files from disk. Primary for development. | | **MemoryLoader** | `memory` | Hydrates compiled artifact items into the runtime metadata registry. | | **DatabaseLoader** | `database` | Stores metadata revisions in control-plane services that explicitly configure it. Not auto-enabled by ObjectStack. | -| **RemoteLoader** | `http` | Fetches metadata from a remote API or git repository. | +| **RemoteLoader** | `http` | Fetches metadata from a remote HTTP API. | ### 3. Granular Capabilities @@ -234,6 +234,6 @@ The canonical `MetadataManagerConfigSchema` and `MetadataFallbackStrategySchema` The Metadata Service is designed to power the CLI. -- **`objectstack compile`**: Validates TypeScript metadata and emits `dist/objectstack.json`. -- **`objectstack publish`**: Uploads compiled JSON to the control plane, which creates a metadata revision and artifact. -- **`objectstack dev`**: Boots ObjectStack from local files or the compiled artifact without a control-plane connection. +- **`os compile`**: Validates TypeScript metadata and emits `dist/objectstack.json`. +- **`os package publish`**: Uploads the compiled `dist/objectstack.json` artifact to the control plane as a versioned package. +- **`os dev`**: Boots ObjectStack from local files or the compiled artifact without a control-plane connection. diff --git a/content/docs/protocol/kernel/plugin-spec.mdx b/content/docs/protocol/kernel/plugin-spec.mdx index a697905197..bed90f862c 100644 --- a/content/docs/protocol/kernel/plugin-spec.mdx +++ b/content/docs/protocol/kernel/plugin-spec.mdx @@ -292,13 +292,16 @@ Define user interfaces using ObjectUI layout DSL: // src/views/account_list.view.ts import { defineView } from '@objectstack/spec'; +// `defineView` takes a view *container* keyed by slot (`list`, `form`, +// `listViews`, `formViews`). A flat `{ name, object, type, layout }` object is +// rejected at build time. The target object goes under `data` (provider +// `object`), and `type` is one of grid | kanban | gallery | calendar | +// timeline | gantt | map | chart | tree — a data table is `grid` (there is no +// `list` type). export default defineView({ - name: 'account_list', - object: 'account', - type: 'list', - - layout: { + list: { type: 'grid', + data: { provider: 'object', object: 'account' }, columns: [ { field: 'name', width: 200 }, { field: 'industry', width: 150 }, @@ -306,16 +309,6 @@ export default defineView({ { field: 'primary_contact', width: 200 }, { field: 'created_at', width: 150 }, ], - - filters: [ - { field: 'industry', operator: 'equals' }, - { field: 'annual_revenue', operator: 'greaterThan' }, - ], - - actions: [ - { type: 'create', label: 'New Account' }, - { type: 'export', label: 'Export to CSV' }, - ], }, }); ``` @@ -721,8 +714,8 @@ npm run build # Build the plugin artifact (validates manifest, bundles entry) os plugin build -# Sign the artifact (separate step; emits a publisher signature) -os plugin sign crm-1.5.0.osplugin +# Sign the artifact (separate step; emits a detached .sig publisher signature) +os plugin sign crm-1.5.0.osplugin --key ./publisher.key.pem # Publish to the ObjectStack package registry os plugin publish @@ -736,15 +729,17 @@ For air-gapped environments or marketplaces, plugins are packaged as **.osplugin # Create the .osplugin artifact (defaults to -.osplugin) os plugin build --out crm-1.5.0.osplugin -# Install from a local artifact into a running runtime (air-gapped) -os package install ./crm-1.5.0.osplugin +# Air-gapped install: `os package install` reads a compiled JSON artifact +# (e.g. ./dist/objectstack.json) into a running runtime — it does not ingest the +# .osplugin tarball, which is uploaded to the marketplace via `os plugin publish`. +os package install ./dist/objectstack.json ``` **.osplugin format:** Reproducible ustar+gzip tarball containing: - Compiled entry (dist/index.mjs) and bundled assets - Manifest (objectstack.plugin.json) - Per-file content digests (`sha256-` integrity map) -- Signature (`SIGNATURE`, added by `os plugin sign`) +- Signature placeholder (`SIGNATURE`, written by `os plugin build`); the real publisher signature is a **detached** `-.osplugin.sig` sidecar produced by `os plugin sign` (signing never modifies the artifact bytes) ## Plugin Testing @@ -880,7 +875,7 @@ npm run build # Build + sign the .osplugin artifact os plugin build -os plugin sign crm-1.5.0.osplugin +os plugin sign crm-1.5.0.osplugin --key ./publisher.key.pem # Publish to the ObjectStack package registry os plugin publish diff --git a/content/docs/protocol/kernel/realtime-protocol.mdx b/content/docs/protocol/kernel/realtime-protocol.mdx index 16409b11cf..20cad36c2b 100644 --- a/content/docs/protocol/kernel/realtime-protocol.mdx +++ b/content/docs/protocol/kernel/realtime-protocol.mdx @@ -648,6 +648,10 @@ Each connection consumes server resources. ### Load Balancing + + **Planned (post-GA) — not in v1.** This section and *Horizontal Scaling with Redis* below describe the future multi-node architecture. The shipping realtime service is **single-instance only**: `RealtimeServicePlugin` accepts just `adapter: 'memory'` (the in-memory `InMemoryRealtimeAdapter`), there is **no WebSocket transport** to load-balance, and **no Redis-backed realtime adapter exists yet**. Multi-node HA — a Redis adapter over the existing `RedisPubSub` in `@objectstack/service-cluster-redis` — is a post-GA fast-follow. + + WebSocket connections are sticky sessions: ```mermaid diff --git a/content/docs/protocol/knowledge.mdx b/content/docs/protocol/knowledge.mdx index 66e67c83ad..0a151d678f 100644 --- a/content/docs/protocol/knowledge.mdx +++ b/content/docs/protocol/knowledge.mdx @@ -14,7 +14,7 @@ Instead, the framework defines a thin **Knowledge Protocol** — a spec-level contract for knowledge sources, documents, hits, and the service that orchestrates them — and ships individual adapters as **plugins**. This mirrors the same pattern already proven by -`IDataEngine` / driver plugins (`driver-sql`, `driver-turso`, +`IDataEngine` / driver plugins (`driver-sql`, `driver-mongodb`, `driver-memory`) and `IStorageService` / S3 + local FS adapters. @@ -82,7 +82,7 @@ delegates the rest to plugins. │ • Permission wrapper (RLS filter) │ │ • knowledge-ragflow │ │ • Event sync (record.* → upsert) │ │ (reference impl, REST) │ │ • Audit + metrics │ │ │ -│ │ │ (turso / dify / │ +│ │ │ (llamaindex / dify / │ │ Registers `knowledge` service │ │ pgvector: planned) │ └──────────────────────────────────────┘ └──────────────────────────────┘ ▲ diff --git a/content/docs/protocol/objectql/index.mdx b/content/docs/protocol/objectql/index.mdx index 1ef7fce36f..b974a2b2f2 100644 --- a/content/docs/protocol/objectql/index.mdx +++ b/content/docs/protocol/objectql/index.mdx @@ -103,7 +103,7 @@ fields: **What you get automatically:** - Database table/collection created -- REST API endpoints: `GET/POST/PUT/DELETE /api/v1/data/customer` +- REST API endpoints: `GET/POST/PATCH/DELETE /api/v1/data/customer` - Admin UI with list view and form - Full-text search index - Field history tracking @@ -180,7 +180,7 @@ db.customer.aggregate([ Validation rules are declared alongside the schema: ```yaml -validation_rules: +validations: - name: end_after_start condition: "end_date < start_date" message: "End date must be after start date" @@ -268,9 +268,11 @@ os package publish Offline sync (on-device SQLite mirror + conflict resolution) is **not yet - implemented** — there is no `offline_sync` capability in the object schema and - no bundled SQLite driver. The example below illustrates the intended direction, - not a shipping feature. + implemented** — there is no `offline_sync` capability in the object schema, and + the offline/sync config schemas that do exist in `@objectstack/spec` + (`OfflineConfigSchema` / `SyncConfigSchema`) are not consumed by any runtime or + client code yet. The example below illustrates the intended direction, not a + shipping feature. **Intended ObjectQL Solution:** because objects are defined once and the same @@ -376,7 +378,7 @@ See [Security Protocol](/docs/protocol/objectql/security) for details. - **Zod Schemas:** `packages/spec/src/data/*.zod.ts` - **TypeScript Types:** `packages/spec/src/data/*.ts` -- **Driver Implementations:** `packages/driver-*` +- **Driver Implementations:** `packages/plugins/driver-*` ## Next Steps diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx index 0c4e8f493b..3a6eac18c6 100644 --- a/content/docs/protocol/objectql/query-syntax.mdx +++ b/content/docs/protocol/objectql/query-syntax.mdx @@ -6,7 +6,7 @@ description: Database-agnostic query language with filtering, joins, aggregation import { Search, Filter, GitMerge, BarChart } from 'lucide-react'; -ObjectQL queries are expressed as **Abstract Syntax Trees (AST)** in JSON format. This enables database-agnostic querying—write once, compile to PostgreSQL, MongoDB, Redis, or any supported driver. +ObjectQL queries are expressed as **Abstract Syntax Trees (AST)** in JSON format. This enables database-agnostic querying—write once, compile to PostgreSQL, MongoDB, SQLite, or any supported driver. All query syntax in this document follows the canonical **`QuerySchema`** defined in `@objectstack/spec` (`packages/spec/src/data/query.zod.ts`). Filtering uses the **`where` + MongoDB-style `$op` object syntax** from `FilterConditionSchema` (`packages/spec/src/data/filter.zod.ts`). @@ -40,10 +40,10 @@ const query: QueryAST = { ``` **Runtime compilation:** -- PostgreSQL → Optimized SQL with JOINs -- MongoDB → Aggregation pipeline with $lookup -- Redis → Key pattern matching + Lua script -- Excel → Filter + VLOOKUP formulas +- PostgreSQL / MySQL → Optimized SQL with JOINs (`@objectstack/driver-sql`) +- MongoDB → Native queries + aggregation pipeline (`@objectstack/driver-mongodb`) +- SQLite → Portable SQL, in-process or in-browser via `@objectstack/driver-sqlite-wasm` +- In-Memory → In-process evaluation, no external database (`@objectstack/driver-memory`) --- @@ -606,8 +606,8 @@ const query: QueryAST = { limit: 10, }; -// PostgreSQL: Uses tsvector/tsquery -// MongoDB: Uses $text index +// The engine expands `search` into an `$or` of `$contains` across the object's +// searchable fields (ADR-0061); every driver runs it as native $or/$contains. ``` ### Joins @@ -760,16 +760,17 @@ const monthlyRevenue: QueryAST = { ## 9. Error Handling -### Invalid Query +### Unknown Fields Are Tolerated + +Unknown field **names** are not rejected — a projected field that doesn't exist on the +object is **silently dropped** (matching OData / `SELECT *` tolerance), so a stale field +reference never fails the whole query: ```typescript -try { - await engine.find('customer', { - where: { invalid_field: 'value' }, // Field doesn't exist - }); -} catch (error) { - // QueryValidationError: Field 'invalid_field' does not exist on object 'customer' -} +const rows = await engine.find('customer', { + fields: ['name', 'nonexistent'], // `nonexistent` is not on the schema +}); +// Returns each row with `name`; `nonexistent` is omitted — no error thrown. ``` ### Security Violations @@ -780,7 +781,7 @@ try { where: { owner_id: { $ne: currentUser.id } }, }); } catch (error) { - // PermissionError: Access denied to object 'account' + // PermissionDeniedError: [Security] Access denied to object 'account' } ``` diff --git a/content/docs/protocol/objectql/schema.mdx b/content/docs/protocol/objectql/schema.mdx index 05955848ca..b1ae119412 100644 --- a/content/docs/protocol/objectql/schema.mdx +++ b/content/docs/protocol/objectql/schema.mdx @@ -445,8 +445,8 @@ account_id: label: Account reference: account required: true - referenceFilters: - - "is_active = true" # Only show active accounts + lookupFilters: + - { field: is_active, operator: eq, value: true } # Only show active accounts project_id: type: master_detail @@ -539,7 +539,7 @@ Optimize query performance with indexes: ```yaml indexes: - # Single-field index (also via field.index: true) + # Single-field unique index - fields: [email] unique: true @@ -665,7 +665,7 @@ Tenant A cannot see Tenant B's records. Schema changes flow through the `os` CLI: validate the config against the protocol schema, compile it to a JSON artifact, compare it against the previously deployed -config to surface breaking changes, then publish the artifact to ObjectOS Cloud. +config to surface breaking changes, then publish the artifact to ObjectStack Cloud. ```bash # 1. Validate the config against the protocol schema @@ -677,7 +677,7 @@ os build # 3. Compare two configs to detect breaking changes os diff ./before.json ./after.json -# 4. Publish the compiled artifact as a versioned package to ObjectOS Cloud +# 4. Publish the compiled artifact as a versioned package to ObjectStack Cloud os package publish # 5. Roll back / forward by installing a prior package version @@ -744,10 +744,10 @@ fields: ### Performance Optimization ```yaml -# Index frequently queried fields +# Enforce uniqueness on frequently queried fields; declare additional +# query indexes in the object-level `indexes[]` array email: type: text - index: true unique: true # Use appropriate field types @@ -759,8 +759,6 @@ status: attachment: type: file label: Attachment - fileAttachmentConfig: - storageProvider: s3 # Offload to object storage ``` ## Examples: Real-World Schemas diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx index 0f71a5002e..5883a7e912 100644 --- a/content/docs/protocol/objectql/security.mdx +++ b/content/docs/protocol/objectql/security.mdx @@ -99,7 +99,7 @@ Beyond the four CRUD flags, the schema also exposes lifecycle and super-user gra | Flag | Meaning | | --- | --- | | `allowCreate` / `allowRead` / `allowEdit` / `allowDelete` | Standard CRUD | -| `allowTransfer` | Change record ownership — *operation pending (M2); RBAC gate pre-mapped (#1883)* | +| `allowTransfer` | Change record ownership (assign/reassign/disown `owner_id`) — *enforced now via the insert/update `owner_id` guard (#3004); the dedicated `transfer` op is still M2* | | `allowRestore` | Restore from trash (undelete) — *operation pending (M2); RBAC gate pre-mapped (#1883)* | | `allowPurge` | Permanently delete (hard delete / GDPR) — *operation pending (M2); RBAC gate pre-mapped (#1883)* | | `viewAllRecords` | Read every record, bypassing sharing & ownership | @@ -365,19 +365,20 @@ Set `deterministicEncryption: true` to allow equality queries on the ciphertext, Object- and field-level history is opt-in metadata, not a free-form `enable.audit` block. -- **Field history** — set `trackHistory: true` on the object (`ObjectCapabilities` in `object.zod.ts`) to record per-field changes. -- **Per-field audit trail** — set `auditTrail: true` on an individual field to track every change with user and timestamp. +- **Object history tab** — set `enable.trackHistory: true` on the object (the `ObjectCapabilities` block in `object.zod.ts`) to surface the record History tab. +- **Per-field history** — set `trackHistory: true` on an individual field to render that field's value changes on the record activity timeline. (The former per-field `auditTrail` flag was pruned in 2026-06 — it had no runtime consumer.) - **RLS audit** — removed (2026-07, ADR-0056 D8): the `RLSAuditConfigSchema`/`RLSAuditEventSchema` shapes were never emitted or read by the runtime RLS path and were deleted from the spec. Enforcement decisions surface today through the security plugin's fail-closed warnings and the standard audit log (`plugin-audit`), not a dedicated RLS event stream. ```yaml # account.object.yml name: account -trackHistory: true +enable: + trackHistory: true fields: annual_revenue: type: currency - auditTrail: true + trackHistory: true ``` --- diff --git a/content/docs/protocol/objectql/state-machine.mdx b/content/docs/protocol/objectql/state-machine.mdx index 52bc4aec6c..7ac0c3d438 100644 --- a/content/docs/protocol/objectql/state-machine.mdx +++ b/content/docs/protocol/objectql/state-machine.mdx @@ -51,8 +51,11 @@ export const PurchaseRequest = ObjectSchema.create({ name: 'purchase_status_flow', label: 'Purchase Status Flow', field: 'status', - // Validated on update; insert sets the initial state from the - // `default: true` select option (no separate `initial` key). + // This rule governs UPDATE transitions only (`events: ['update']` + // below). `initialStates` — the optional FSM entry point that locks + // which states a record may be CREATED in (#3165) — is checked on + // INSERT, so to use it here you'd also add 'insert' to `events`. + // Otherwise the field's `default: true` option is the starting value. events: ['update'], message: 'Invalid purchase status transition.', transitions: { @@ -71,12 +74,13 @@ export const PurchaseRequest = ObjectSchema.create({ ## Structure -A `state_machine` rule shares the common validation-rule fields (`name`, `label`, `message`, `severity`, `events`, `priority`, `active`) with its siblings, plus two of its own: +A `state_machine` rule shares the common validation-rule fields (`name`, `label`, `message`, `severity`, `events`, `priority`, `active`) with its siblings, plus its own: | Field | Type | Meaning | |:---|:---|:---| | `field` | `string` | The state field this rule governs (e.g. `status`). | -| `transitions` | `Record` | Map of `{ currentValue: [allowedNextValues] }` — the legal edges. | +| `transitions` | `Record` | Map of `{ currentValue: [allowedNextValues] }` — the legal edges (enforced on **update**). | +| `initialStates` | `string[]` (optional) | States a record may be **created** in. When set, an insert whose `field` value falls outside this list is rejected (`invalid_initial_state`) — the FSM entry point (#3165). Omit to keep the legacy no-check-on-insert behavior. | ### Transitions @@ -91,7 +95,7 @@ transitions: { ### Enforcement semantics -- **On insert**, the rule is a no-op — there is no prior state to transition from. The field-level `select` check already constrains the initial value, and the initial state is the option marked `default: true`. +- **On insert**, the `transitions` table is not consulted — there is no prior state to transition from. If the rule declares `initialStates`, the created value must be one of them, or the write is rejected with `invalid_initial_state` (the FSM entry point). Without `initialStates`, insert is a no-op and the starting value is constrained only by the field-level `select` check — typically the option marked `default: true`. - **On update**, if the state field changed and the new value is **not** in `transitions[oldValue]`, the write is rejected. - The check is **lenient where it cannot reason**: if the prior state is not described by the table (e.g. legacy or externally-written data), it does not block. - Only a rule with `severity: 'error'` (the default) blocks the write; `warning`/`info` are logged. diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx index 3c5e7a6a72..0f96207cd3 100644 --- a/content/docs/protocol/objectql/types.mdx +++ b/content/docs/protocol/objectql/types.mdx @@ -154,7 +154,6 @@ email: label: Email Address required: true unique: true - index: true ``` **Validation:** @@ -498,8 +497,10 @@ account_id: label: Account reference: account required: true - referenceFilters: - - "is_active = true" + lookupFilters: + - field: is_active + operator: eq + value: true deleteBehavior: set_null # or restrict, cascade ``` @@ -507,10 +508,9 @@ account_id: **Query behavior:** ```typescript -// Automatic JOIN -const opportunities = await ObjectQL.query({ - object: 'opportunity', - fields: ['name', 'account.company_name'] // Joins account +// Expand the account lookup +const opportunities = await engine.find('opportunity', { + fields: ['name', 'account.company_name'] // Expands account }); ``` @@ -754,11 +754,8 @@ metadata: **Query support:** ```typescript // Query JSON properties -const products = await ObjectQL.query({ - object: 'product', - filters: [ - ['metadata.color', '=', 'red'] - ] +const products = await engine.find('product', { + where: { 'metadata.color': 'red' } }); ``` @@ -798,7 +795,6 @@ Structured address with geocoding. billing_address: type: address label: Billing Address - allowGeocoding: true # Allow address-to-coordinate conversion ``` **Structure:** @@ -837,16 +833,15 @@ office_location: } ``` -**Query:** -```typescript -// Find nearby locations (within 10 miles) -const nearby = await ObjectQL.query({ - object: 'store', - filters: [ - ['location', 'near', { lat: 37.7749, lng: -122.4194, distance: 10, unit: 'miles' }] - ] -}); -``` + +Proximity / radius ("near") search is **not** a built-in filter operator. +ObjectQL's filter language exposes only `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, +`$lte`, `$in`, `$nin`, `$between`, `$contains`, `$notContains`, `$startsWith`, +`$endsWith`, `$null`, and `$exists`. A `location` field stores coordinates; +geospatial querying is not part of ObjectQL's portable filter language — it would +rely on the underlying database's native geospatial support (e.g. MongoDB +geospatial indexes), which the SQL and in-memory drivers do not provide. + **Database mapping:** - PostgreSQL: `POINT` or `GEOGRAPHY` @@ -861,10 +856,7 @@ File attachment reference. avatar: type: file label: Profile Picture - fileAttachmentConfig: - allowedMimeTypes: [image/png, image/jpeg] - maxSize: 5242880 # 5MB - storageProvider: s3 + multiple: false # set true to store an array of file references ``` **Storage:** @@ -880,7 +872,7 @@ avatar: **Storage backends:** - `local`: Server filesystem - `s3`: Amazon S3 -- `azure`: Azure Blob Storage +- `azure_blob`: Azure Blob Storage - `gcs`: Google Cloud Storage **Features:** @@ -898,21 +890,15 @@ Image file with transformations. product_image: type: image label: Product Image - fileAttachmentConfig: - allowedMimeTypes: [image/png, image/jpeg, image/webp] - imageValidation: - maxWidth: 4096 - maxHeight: 4096 - generateThumbnails: true - thumbnailSizes: - - { name: thumbnail, width: 150, height: 150, crop: true } - - { name: large, width: 1200, height: 800, crop: false } ``` **Features:** -- Image dimension validation (`minWidth`/`maxWidth`/`minHeight`/`maxHeight`, `aspectRatio`) -- Thumbnail generation (`generateThumbnails`, `thumbnailSizes`) -- EXIF handling (`preserveMetadata`, `autoRotate`) + +Per-field image-processing options (thumbnail generation, dimension validation, +EXIF handling) are **not** Field-schema properties. Those capabilities are +configured on the file-storage connector's `contentProcessing` +(`generateThumbnails`, `thumbnailSizes`, …). The `image` field type itself stores +the uploaded file reference. --- diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 3d6d5af3e4..aa9bca78cb 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -182,7 +182,7 @@ interface Action { confirmText?: string; // Confirmation message before execution successMessage?: string; // Toast shown after success errorMessage?: string; // Toast shown on failure (overrides the raw error) - undoable?: boolean; // Offer an Undo affordance after a single-record update succeeds + undoable?: boolean; // Offer an Undo affordance after a single-record update succeeds (experimental — see note below) refreshAfter?: boolean; // Reload the view after execution (default false) resultDialog?: ResultDialog; // One-shot reveal of API response values shortcut?: string; // Keyboard shortcut, e.g. "Ctrl+S" @@ -199,7 +199,6 @@ interface Action { ai?: ActionAi; // Opt-in AI tool exposure // Misc - timeout?: number; // Max execution time (ms) aria?: AriaProps; // Accessibility attributes } ``` @@ -248,7 +247,7 @@ Semantics: - **`order` defaults to `0`.** An action with a **negative** `order` is promoted toward the primary slot; a **positive** `order` is demoted toward the overflow menu. - **The sort is stable.** Actions that leave `order` unset (or tie on the same value) keep their original registration order, so adding `order` to one action never reshuffles the others. Setting `order` on nobody is a no-op — existing screens are unaffected. -- **No more fragile registration order.** Previously "who becomes the primary button" depended on the cross-file order in which `defineStack({ actions })` merged its arrays. `order` makes it declarative: a plugin such as [plugin-approvals](/docs/plugins/approvals) can inject an `Approve`/`Reject` decision with a low `order` so it stably outranks app actions, instead of the app having to *hide* its other actions to make room. +- **No more fragile registration order.** Previously "who becomes the primary button" depended on the cross-file order in which `defineStack({ actions })` merged its arrays. `order` makes it declarative: a plugin such as [plugin-approvals](/docs/automation/approvals) can inject an `Approve`/`Reject` decision with a low `order` so it stably outranks app actions, instead of the app having to *hide* its other actions to make room. - When two actions tie on `order`, the record-header renderer MAY additionally prefer the one with `variant: 'primary'` when choosing the primary button. @@ -290,7 +289,7 @@ disabled: "status == 'converted'" # bare field — also resolves to the Prefer the `record.` form: it reads unambiguously and resolves identically on **every** surface an action renders (`record_header`, `record_more`, `list_item`, related lists). The bare-field form is supported for brevity but is easy to confuse with a local variable. -This is a narrower scope than [record-alert](/docs/protocol/objectui/record-alert) conditions (which also expose `os.user` / `os.org` / `os.env`) — action predicates see the record only. +Beyond `record`, action predicates also expose the authenticated user as `ctx.user` (e.g. `record.id == ctx.user.id`) and public feature flags as `features.*` (see [Capability gates](#capability-gates-requiresfeature)) — they are not limited to the record. [record-alert](/docs/protocol/objectui/record-alert) conditions surface the analogous identity values under the `os.*` namespace (`os.user` / `os.org` / `os.env`) instead. #### Capability gates: `requiresFeature` @@ -333,9 +332,9 @@ refreshAfter: true - `confirmText` — message shown in a confirm dialog before the action runs. - `successMessage` — toast shown after a successful run. When omitted the UI shows a generic "Action completed" toast, so set this for any action whose outcome isn't self-evident. - `errorMessage` — toast shown when the action fails; overrides the raw server error with author-controlled copy. -- `undoable` — for a single-record update action, offer an **Undo** affordance in the success toast (and via `Ctrl+Z`). The runtime captures the record's prior values before the write and restores them on undo. Only meaningful for reversible single-record mutations. +- `undoable` — marks a single-record update action as offering an **Undo** affordance in the success toast to restore the record's prior values. **Experimental:** the flag is declared in the schema but no runtime consumer keys off it yet (objectui ships an `UndoManager` but does not read this field), so setting it currently has no effect. - `refreshAfter` — reload the current view after success. -- `mode` — a semantic hint (`create` / `edit` / `delete` / `custom`) the UI uses to pick confirm copy and default variants. +- `mode` — a semantic hint (`create` / `edit` / `delete` / `custom`). Pure metadata with no runtime branching: today only the AI confirmation heuristic reads it (a `delete` mode nudges an AI invocation toward requiring approval), while the UI does not branch on it. ```yaml # A reversible owner reassignment: custom success/error copy + Undo diff --git a/content/docs/protocol/objectui/concept.mdx b/content/docs/protocol/objectui/concept.mdx index 30443ae1e3..c91125966a 100644 --- a/content/docs/protocol/objectui/concept.mdx +++ b/content/docs/protocol/objectui/concept.mdx @@ -464,7 +464,7 @@ customizations: ObjectUI definitions are **Zod schemas**, providing both compile-time and runtime safety: ```typescript -import { FormViewSchema } from '@objectstack/spec'; +import { FormViewSchema } from '@objectstack/spec/ui'; import { z } from 'zod'; // Type inference @@ -472,34 +472,26 @@ type FormView = z.infer; // Runtime validation const config = FormViewSchema.parse({ - name: 'customer_form', - object: 'customer', - layout: { - sections: [ - { label: 'Info', fields: ['name', 'email'] } - ] - } + type: 'simple', + sections: [ + { label: 'Info', columns: 2, fields: ['name', 'email'] } + ] }); // ✅ TypeScript error at compile time const badConfig: FormView = { - name: 123, // ❌ Type 'number' is not assignable to type 'string' + columns: 'two', // ❌ Type 'string' is not assignable to type 'number' }; // ✅ Runtime error with clear message FormViewSchema.parse({ - layout: { - sections: [ - { columns: 'two' } // ❌ Expected number, received string - ] - } + columns: 'two' // ❌ Expected number, received string }); // ZodError: [ // { // "code": "invalid_type", // "expected": "number", -// "received": "string", -// "path": ["layout", "sections", 0, "columns"] +// "path": ["columns"] // } // ] ``` diff --git a/content/docs/protocol/objectui/index.mdx b/content/docs/protocol/objectui/index.mdx index 265e842ed7..57291f5641 100644 --- a/content/docs/protocol/objectui/index.mdx +++ b/content/docs/protocol/objectui/index.mdx @@ -224,25 +224,18 @@ actions: ### FormView: Record Editing -```yaml -FormView.create({ - object: 'project', - layout: { - type: 'tabbed', # or 'simple' | 'wizard' | 'split' | 'drawer' | 'modal' - tabs: [ - { - label: 'Details', - sections: [ - { label: 'Basic Info', columns: 2, fields: ['name', 'status'] }, - { label: 'Dates', columns: 2, fields: ['start_date', 'end_date'] } - ] - }, - { - label: 'Team', - sections: [ - { label: 'Members', fields: ['manager', 'team_lead'] } - ] - } +```typescript +import { defineView } from '@objectstack/spec/ui'; + +defineView({ + form: { + type: 'tabbed', // or 'simple' | 'wizard' | 'split' | 'drawer' | 'modal' + data: { provider: 'object', object: 'project' }, + // In tabbed forms each section renders as its own tab. + sections: [ + { label: 'Basic Info', columns: 2, fields: ['name', 'status'] }, + { label: 'Dates', columns: 2, fields: ['start_date', 'end_date'] }, + { label: 'Team', fields: ['manager', 'team_lead'] } ] } }); @@ -255,22 +248,24 @@ FormView.create({ ### ListView: Data Tables -```yaml -ListView.create({ - object: 'project', - type: 'grid', # or 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' - columns: [ - { field: 'name', width: 200, sortable: true }, - { field: 'status', width: 120, filter: true }, - { field: 'due_date', width: 150, sortable: true } - ], - filters: { - default: [{ field: 'status', operator: 'in', value: ['active', 'pending'] }] - }, - actions: [ - { type: 'standard_new', label: 'New Project' }, - { type: 'standard_edit', label: 'Edit' } - ] +```typescript +import { defineView } from '@objectstack/spec/ui'; + +defineView({ + list: { + type: 'grid', // or 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree' + data: { provider: 'object', object: 'project' }, + columns: [ + { field: 'name', width: 200, sortable: true }, + { field: 'status', width: 120 }, + { field: 'due_date', width: 150, sortable: true } + ], + filter: [{ field: 'status', operator: 'in', value: ['active', 'pending'] }], + // Actions reference registered Action IDs. Row actions are top-level; + // toolbar buttons live under `userActions`. + userActions: { buttons: ['new_project'] }, + rowActions: ['edit'] + } }); ``` @@ -282,41 +277,36 @@ ListView.create({ ### Dashboard: Analytics Composition -```yaml +```typescript +import { Dashboard } from '@objectstack/spec/ui'; + Dashboard.create({ name: 'sales_overview', label: 'Sales Overview', - layout: { - rows: [ - { - widgets: [ - { type: 'metric', title: 'Revenue', value: '$1.2M', span: 3 }, - { type: 'metric', title: 'Deals', value: '47', span: 3 }, - { type: 'metric', title: 'Win Rate', value: '68%', span: 3 }, - { type: 'metric', title: 'Avg Deal', value: '$25K', span: 3 } - ] - }, - { - widgets: [ - { type: 'chart', chartType: 'line', title: 'Revenue Trend', span: 8 }, - { type: 'chart', chartType: 'pie', title: 'By Stage', span: 4 } - ] - }, - { - widgets: [ - { type: 'list', object: 'opportunity', view: 'closing_soon', span: 12 } - ] - } - ] - } + columns: 12, // 12-column grid + gap: 4, + // Flat widget list. Each widget binds to a semantic-layer `dataset` + // (ADR-0021) and picks its `dimensions`/`values` by name; position via + // `layout: { x, y, w, h }` (omit to auto-flow). + widgets: [ + // Row 1: KPI metric cards + { id: 'revenue', type: 'metric', title: 'Revenue', dataset: 'sales_summary', values: ['revenue'], layout: { x: 0, y: 0, w: 3, h: 2 } }, + { id: 'deals', type: 'metric', title: 'Deals', dataset: 'sales_summary', values: ['deal_count'], layout: { x: 3, y: 0, w: 3, h: 2 } }, + { id: 'win_rate', type: 'metric', title: 'Win Rate', dataset: 'sales_summary', values: ['win_rate'], layout: { x: 6, y: 0, w: 3, h: 2 } }, + { id: 'avg_deal', type: 'metric', title: 'Avg Deal', dataset: 'sales_summary', values: ['avg_deal'], layout: { x: 9, y: 0, w: 3, h: 2 } }, + // Row 2: charts + { id: 'revenue_trend', type: 'line', title: 'Revenue Trend', dataset: 'sales_by_month', dimensions: ['month'], values: ['revenue'], layout: { x: 0, y: 2, w: 8, h: 4 } }, + { id: 'by_stage', type: 'pie', title: 'By Stage', dataset: 'pipeline_by_stage', dimensions: ['stage'], values: ['amount'], layout: { x: 8, y: 2, w: 4, h: 4 } }, + // Row 3: tabular block + { id: 'closing_soon', type: 'table', title: 'Closing Soon', dataset: 'opportunities_closing_soon', dimensions: ['name'], values: ['amount'], layout: { x: 0, y: 6, w: 12, h: 4 } } + ] }); ``` -**Widget Types:** -- **Metric:** KPI cards with trend indicators -- **Chart:** Bar, line, pie, donut, area, scatter -- **List:** Embedded list views -- **Custom:** Register your own widget components +**Widget Types** (each widget's `type` is a chart type): +- **Metric / KPI:** single-value cards (`metric`, `kpi`, `gauge`, `bullet`) +- **Chart:** `bar`, `column`, `line`, `area`, `pie`, `donut`, `funnel`, `scatter`, `radar`, `treemap`, `sankey` +- **Tabular:** `table`, `pivot` ## Design Principles @@ -339,9 +329,11 @@ function TaskList() { } // ✅ Declarative (ObjectUI) -ListView.create({ - object: 'task', - columns: [{ field: 'title' }, { field: 'status' }] +defineView({ + list: { + data: { provider: 'object', object: 'task' }, + columns: [{ field: 'title' }, { field: 'status' }] + } }); ``` diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index ceef8f1171..bc7a2b515a 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -15,7 +15,7 @@ Instead of giving you infinite layout freedom (and infinite ways to break respon - **12-Column Grid:** All layouts use a 12-column responsive grid - **Section-Based:** Pages are composed of sections, sections contain fields/widgets - **Auto-Responsive:** Breakpoints applied automatically (no media queries) -- **Platform-Agnostic:** Same DSL renders as CSS Grid (web), Auto Layout (iOS), ConstraintLayout (Android) +- **Platform-Agnostic:** The DSL describes structure, not raw CSS — the web renderer maps it to CSS Grid today. Native iOS/Android renderers (Auto Layout / ConstraintLayout) are a design goal, not yet implemented. **Why Constraints?** - **Consistency:** All forms look professionally designed @@ -726,12 +726,14 @@ interface Page { label: string; description?: string; icon?: string; - type?: 'record' | 'home' | 'app' | 'utility' | 'list' - | 'dashboard' | 'form' | 'record_detail' - | 'record_review' | 'overview' | 'blank'; // default 'record' + type?: 'record' | 'home' | 'app' | 'utility' | 'list'; // default 'record' + // NOTE: dashboard / form / record_detail / record_review / overview / blank + // were removed from the live enum (ADR-0049 enforce-or-remove — no renderer + // ever shipped). They live in PAGE_TYPE_ROADMAP; authoring one now fails + // validation. See page.zod.ts (PageTypeSchema / PAGE_TYPE_ROADMAP). object?: string; // bound object for record pages template?: string; // layout template name, default 'default' - regions: PageRegion[]; // named regions, each holding components + regions?: PageRegion[]; // named regions, each holding components (optional; defaults to []) } interface PageRegion { @@ -794,7 +796,7 @@ interface ResponsiveColumns { ### Visibility Rule Visibility is a single **CEL expression** string, not a structured rule object. -Since [ADR-0089](/docs/adr) the one canonical key is **`visibleWhen`** across every +Since [ADR-0089](https://github.com/objectstack-ai/framework/blob/main/docs/adr/0089-unify-visibility-predicate-naming.md) the one canonical key is **`visibleWhen`** across every layer — data fields, view form sections/fields, and page components — aligning with the `readonlyWhen` / `requiredWhen` family. The element is shown only when the expression evaluates truthy. diff --git a/content/docs/protocol/objectui/record-alert.mdx b/content/docs/protocol/objectui/record-alert.mdx index c42e9554ed..76c953bbcd 100644 --- a/content/docs/protocol/objectui/record-alert.mdx +++ b/content/docs/protocol/objectui/record-alert.mdx @@ -28,7 +28,7 @@ Common use cases: action?: { actionName: string, // resolves from the object's actions[] label?: string, // overrides action.label - variant?: 'default' | 'destructive' | 'outline' | 'ghost' | 'link', + variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'link', }, dismissible?: boolean, // X button in top-right dismissKey?: string, // localStorage key suffix; defaults to title @@ -145,7 +145,6 @@ Pick a stable `dismissKey` when the title is i18n'd — otherwise translation ch | One-line warning at the top of a record page | **`record:alert`** | | Toolbar/menu with multiple related actions | `record:quick_actions` | | Read-only status badge in the header | `record:highlights` | -| Validation error on a specific field | `field:*` `errors` slot | | App-wide notice (across all pages) | App-level banner plugin (not metadata-driven) | ## Related diff --git a/content/docs/protocol/objectui/widget-contract.mdx b/content/docs/protocol/objectui/widget-contract.mdx index f07a7d654b..63a899a149 100644 --- a/content/docs/protocol/objectui/widget-contract.mdx +++ b/content/docs/protocol/objectui/widget-contract.mdx @@ -110,7 +110,7 @@ Each field declares a `type`. The renderer auto-infers a widget from the type; a | Date & time | `date`, `datetime`, `time` | | Logic | `boolean`, `toggle` | | Selection | `select`, `multiselect`, `radio`, `checkboxes`, `tags` | -| Relational | `lookup`, `master_detail`, `tree` | +| Relational | `lookup`, `master_detail`, `tree`, `user` | | Media | `image`, `file`, `avatar`, `video`, `audio`, `signature`, `qrcode` | | Calculated/system | `formula`, `summary`, `autonumber` | | Embedded | `composite`, `repeater`, `record`, `json` | diff --git a/content/docs/references/ai/meta.json b/content/docs/references/ai/meta.json index 6f50bf15b1..46db5a1969 100644 --- a/content/docs/references/ai/meta.json +++ b/content/docs/references/ai/meta.json @@ -1,16 +1,19 @@ { "title": "AI Protocol", "pages": [ + "---Agents & Tools---", "agent", - "conversation", - "embedding", - "knowledge-document", - "knowledge-source", "mcp", - "model-registry", "skill", "solution-blueprint", "tool", + "---Knowledge & RAG---", + "embedding", + "knowledge-document", + "knowledge-source", + "---Models & Runtime---", + "conversation", + "model-registry", "usage" ] } \ No newline at end of file diff --git a/content/docs/references/api/meta.json b/content/docs/references/api/meta.json index 33dbbe5c94..6561b8ad10 100644 --- a/content/docs/references/api/meta.json +++ b/content/docs/references/api/meta.json @@ -1,41 +1,44 @@ { "title": "API Protocol", "pages": [ - "analytics", - "auth", - "auth-endpoints", - "automation-api", + "---Contract & Routing---", "batch", - "connector", "contract", - "core-services", "discovery", - "dispatcher", "documentation", "endpoint", "errors", - "events", - "export", + "protocol", + "registry", + "router", + "versioning", + "---Transport & Realtime---", + "dispatcher", "graphql", "http", "http-cache", + "odata", + "query-adapter", + "realtime", + "realtime-shared", + "rest-server", + "websocket", + "---Service APIs---", + "analytics", + "auth", + "auth-endpoints", + "automation-api", + "connector", + "core-services", + "events", + "export", "identity", "metadata", "metadata-plugin", "notification", - "odata", "package-api", "package-registry", "plugin-rest-api", - "protocol", - "query-adapter", - "realtime", - "realtime-shared", - "registry", - "rest-server", - "router", - "storage", - "versioning", - "websocket" + "storage" ] } \ No newline at end of file diff --git a/content/docs/references/automation/meta.json b/content/docs/references/automation/meta.json index bb448181dd..ff757fa5aa 100644 --- a/content/docs/references/automation/meta.json +++ b/content/docs/references/automation/meta.json @@ -1,20 +1,23 @@ { "title": "Automation Protocol", "pages": [ - "approval", - "bpmn-interop", - "connector", + "---Flow & Execution---", "control-flow", - "etl", "execution", "flow", - "job", "node-executor", - "offline", "state-machine", - "sync", "time-relative-trigger", "trigger-registry", - "webhook" + "---Integration & Data---", + "bpmn-interop", + "connector", + "etl", + "offline", + "sync", + "webhook", + "---Approvals & Jobs---", + "approval", + "job" ] } \ No newline at end of file diff --git a/content/docs/references/cloud/meta.json b/content/docs/references/cloud/meta.json index 4c7825db1d..59edce314b 100644 --- a/content/docs/references/cloud/meta.json +++ b/content/docs/references/cloud/meta.json @@ -1,18 +1,21 @@ { "title": "Cloud Protocol", "pages": [ - "app-store", - "developer-portal", + "---Environments & Packages---", "environment", "environment-artifact", "environment-package", - "marketplace", - "marketplace-admin", "package", "package-version", - "plugin-security", "provisioning", "template-manifest", + "---Marketplace & Distribution---", + "app-store", + "developer-portal", + "marketplace", + "marketplace-admin", + "---Tenancy & Security---", + "plugin-security", "tenant" ] } \ No newline at end of file diff --git a/content/docs/references/data/meta.json b/content/docs/references/data/meta.json index 7c243d7a63..3b7f0e4678 100644 --- a/content/docs/references/data/meta.json +++ b/content/docs/references/data/meta.json @@ -1,26 +1,30 @@ { "title": "Data Protocol", "pages": [ + "---Objects & Fields---", + "field", + "hook", + "hook-body", + "mapping", + "object", + "validation", + "---Query & Analytics---", "analytics", "data-engine", - "datasource", "date-macros", - "document", + "filter", + "query", + "---Datasources & Drivers---", + "datasource", "driver", "driver-nosql", "driver-sql", "external-catalog", "external-lookup", + "---Documents & Seed---", + "document", "feed", - "field", - "filter", - "hook", - "hook-body", - "mapping", - "object", - "query", "seed", - "seed-loader", - "validation" + "seed-loader" ] } \ No newline at end of file diff --git a/content/docs/references/integration/meta.json b/content/docs/references/integration/meta.json index ee2b6fb799..4ccb122e5a 100644 --- a/content/docs/references/integration/meta.json +++ b/content/docs/references/integration/meta.json @@ -1,15 +1,18 @@ { "title": "Integration Protocol", "pages": [ + "---Connectors---", "connector", "connector-auth", - "http", "mapping", + "translation", + "---Transport & Storage---", + "http", "message-queue", - "misc", "object-storage", "offline", - "tenant", - "translation" + "---Tenancy---", + "misc", + "tenant" ] } \ No newline at end of file diff --git a/content/docs/references/kernel/meta.json b/content/docs/references/kernel/meta.json index cef12715f1..21bcfe12cf 100644 --- a/content/docs/references/kernel/meta.json +++ b/content/docs/references/kernel/meta.json @@ -1,35 +1,39 @@ { "title": "Kernel Protocol", "pages": [ + "---Plugin Lifecycle---", + "plugin", + "plugin-lifecycle-advanced", + "plugin-lifecycle-events", + "plugin-loading", + "plugin-registry", + "plugin-runtime", + "plugin-structure", + "plugin-validator", + "---Plugin Security & Dependencies---", + "dependency-resolution", + "manifest", + "plugin-capability", + "plugin-security", + "plugin-security-advanced", + "plugin-versioning", + "---Packages---", + "package-artifact", + "package-registry", + "package-upgrade", + "---Metadata & Runtime---", "cli-extension", "cluster", "context", - "dependency-resolution", "dev-plugin", "execution-context", "feature", - "manifest", "metadata-customization", "metadata-loader", "metadata-persistence", "metadata-plugin", "metadata-protection", "misc", - "package-artifact", - "package-registry", - "package-upgrade", - "plugin", - "plugin-capability", - "plugin-lifecycle-advanced", - "plugin-lifecycle-events", - "plugin-loading", - "plugin-registry", - "plugin-runtime", - "plugin-security", - "plugin-security-advanced", - "plugin-structure", - "plugin-validator", - "plugin-versioning", "service-registry", "startup-orchestrator", "state-machine" diff --git a/content/docs/references/system/meta.json b/content/docs/references/system/meta.json index cba7445bd7..f1484ef6f0 100644 --- a/content/docs/references/system/meta.json +++ b/content/docs/references/system/meta.json @@ -1,44 +1,49 @@ { "title": "System Protocol", "pages": [ + "---Config & Settings---", "app-install", - "audit", "auth-config", - "book", - "cache", - "change-management", - "collaboration", - "core-services", "deploy-bundle", - "disaster-recovery", - "doc", "email-config", "email-template", - "encryption", "environment-artifact", + "license", + "migration", + "provisioning", + "registry-config", + "settings-client", + "settings-manifest", + "tenant", + "---Services & Infrastructure---", + "cache", + "core-services", "http-server", - "incident-response", "job", - "license", - "logging", "message-queue", "metadata-loader", "metadata-persistence", - "metrics", - "migration", "notification", "object-storage", - "provisioning", - "registry-config", "search-engine", + "translation", + "worker", + "---Observability---", + "audit", + "logging", + "metrics", + "tracing", + "---Security & Compliance---", + "change-management", + "disaster-recovery", + "encryption", + "incident-response", "security-context", - "settings-client", - "settings-manifest", "supplier-security", - "tenant", - "tracing", "training", - "translation", - "worker" + "---Content & Collaboration---", + "book", + "collaboration", + "doc" ] } \ No newline at end of file diff --git a/content/docs/references/ui/meta.json b/content/docs/references/ui/meta.json index 40441070e3..dd2bf468a0 100644 --- a/content/docs/references/ui/meta.json +++ b/content/docs/references/ui/meta.json @@ -1,27 +1,31 @@ { "title": "UI Protocol", "pages": [ + "---Apps & Navigation---", "action", - "animation", "app", + "page", + "portal", + "view", + "---Visualization---", "chart", "component", "dashboard", "dataset", + "report", + "widget", + "---Interaction & Layout---", + "animation", "dnd", - "http", - "i18n", "keyboard", - "notification", "offline", - "page", - "portal", - "report", "responsive", - "sharing", "theme", "touch", - "view", - "widget" + "---Platform---", + "http", + "i18n", + "notification", + "sharing" ] } \ No newline at end of file diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index bfd8fd3248..2d3bbd0a2e 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -61,10 +61,10 @@ Appearance and visualization configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **startDateField** | `string` | ✅ | | -| **endDateField** | `string` | optional | | -| **titleField** | `string` | ✅ | | -| **colorField** | `string` | optional | | +| **startDateField** | `string` | ✅ | Field providing the event start date/time | +| **endDateField** | `string` | optional | Field providing the event end date/time (defaults to a single-day event) | +| **titleField** | `string` | ✅ | Field displayed as the event title | +| **colorField** | `string` | optional | Field whose value determines the event color | --- @@ -217,11 +217,11 @@ Gallery/card view configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **startDateField** | `string` | ✅ | | -| **endDateField** | `string` | ✅ | | -| **titleField** | `string` | ✅ | | -| **progressField** | `string` | optional | | -| **dependenciesField** | `string` | optional | | +| **startDateField** | `string` | ✅ | Field providing the task start date | +| **endDateField** | `string` | ✅ | Field providing the task end date | +| **titleField** | `string` | ✅ | Field displayed as the task title | +| **progressField** | `string` | optional | Field providing the task completion percentage | +| **dependenciesField** | `string` | optional | Field listing the task's predecessor (dependency) record ids | | **colorField** | `string` | optional | Field that drives the bar color | | **parentField** | `string` | optional | Field holding the parent task id (builds the summary → step tree) | | **typeField** | `string` | optional | Field whose value maps to task/summary/milestone | @@ -353,13 +353,13 @@ List chart view configuration | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | | -| **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | | -| **gantt** | `Record` | optional | | +| **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | +| **gantt** | `Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | -| **tree** | `Record` | optional | | +| **tree** | `Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **description** | `string` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | @@ -441,13 +441,13 @@ List chart view configuration | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | | -| **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | | -| **gantt** | `Record` | optional | | +| **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | +| **gantt** | `Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | -| **tree** | `Record` | optional | | +| **tree** | `Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **description** | `string` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | diff --git a/content/docs/releases/implementation-status.mdx b/content/docs/releases/implementation-status.mdx index 50f8d207a4..f1fd7f370f 100644 --- a/content/docs/releases/implementation-status.mdx +++ b/content/docs/releases/implementation-status.mdx @@ -89,7 +89,7 @@ This matrix is generated from actual codebase analysis and represents the curren | **Encryption** | ❌ | 📋 | Planned | | **Compliance** | ❌ | 📋 | Planned | | **Masking** | ❌ | 📋 | Planned | -| **Notification** | @objectstack/service-messaging, @objectstack/service-feed | 🟡 | Framework pipeline shipped; objectui bell cut-over remains | +| **Notification** | @objectstack/service-messaging | 🟡 | Framework pipeline shipped; objectui bell cut-over remains | | **Change Management** | ❌ | 📋 | Planned | | **Collaboration** | ❌ | 📋 | Planned | @@ -250,11 +250,11 @@ The data (`/data`), metadata (`/meta`), and batch endpoints are implemented in ` | **Webhook** | ✅ | ❌ | ✅ | ✅ `@objectstack/plugin-webhooks` | | **ETL** | ✅ | ❌ | ✅ | 📋 Plugin | | **Sync** | ✅ | ❌ | ✅ | 📋 Plugin | -| **Trigger Registry** | ✅ | ❌ | ✅ | ✅ `plugin-trigger-record-change` / `plugin-trigger-schedule` | +| **Trigger Registry** | ✅ | ❌ | ✅ | ✅ `trigger-record-change` / `trigger-schedule` | **Notes:** - Hook system is implemented in ObjectQL (beforeFind, afterInsert, etc.) — this is data-layer eventing, not workflow automation -- The flow/workflow engine ships in `@objectstack/service-automation` (`engine.ts`, builtin nodes, `plugin.ts`); approvals, webhooks, and triggers ship as `plugin-approvals`, `plugin-webhooks`, `plugin-trigger-record-change`, and `plugin-trigger-schedule` +- The flow/workflow engine ships in `@objectstack/service-automation` (`engine.ts`, builtin nodes, `plugin.ts`); approvals, webhooks, and triggers ship as `plugin-approvals`, `plugin-webhooks`, `trigger-record-change`, and `trigger-schedule` - ETL and Sync protocols are defined but not yet implemented as plugins - Discovery API reports automation service as `unavailable` until a plugin is registered @@ -288,7 +288,7 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity - Client SDK supports bearer token header — but token validation requires the auth plugin - Auth route (`/auth/*`) only appears in Discovery when the auth plugin is registered - Fine-grained authorization (RLS, sharing, territory) is internal to the auth plugin -- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, positions, permissions). Default `member_default` permission set ships a wildcard RLS rule `organization_id == current_user.organization_id` plus per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_position_permission_set`. **Anonymous traffic is denied by default** (the ADR-0056 D2 default-deny flip landed: `requireAuth` defaults to `true`); an explicit `requireAuth: false` opt-out logs a boot-time warning, and public forms do not depend on any fall-open — they carry a declaration-derived `publicFormGrant` (ADR-0056 Option A). +- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, positions, permissions). Tenant isolation is enforced as a Layer 0 tenant wall (`plugin-security/tenant-layer.ts`, ADR-0095 D1) that AND-composes `organization_id == current_user.organization_id` ahead of and independently of business RLS — the earlier wildcard `tenant_isolation` RLS policy on `member_default` was retired (an OR-merged business policy could widen it). The default `member_default` set still ships per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_position_permission_set`. **Anonymous traffic is denied by default** (the ADR-0056 D2 default-deny flip landed: `requireAuth` defaults to `true`); an explicit `requireAuth: false` opt-out logs a boot-time warning, and public forms do not depend on any fall-open — they carry a declaration-derived `publicFormGrant` (ADR-0056 Option A). - **OWD / sharing-model enforcement is live and proven end-to-end (ADR-0056)**: `private`, `public_read`, `public_read_write`, and `controlled_by_parent` are enforced through `plugin-sharing` + `plugin-security` and verified by dogfood proofs over the real HTTP stack. `object.sharingModel` accepts the canonical OWD vocabulary only (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) — the legacy `read` / `read_write` / `full` aliases were removed from the enum (ADR-0090 D4), and an unset `sharingModel` on a custom object resolves to `private` (ADR-0090 D1). RLS owner policies resolve `current_user.email` in addition to `id` / `organization_id` / `positions` (#2054). Permission sets may declare `isDefault: true` as the install-time suggestion to bind the set to the built-in `everyone` position (ADR-0090 D5, superseding the ADR-0056 D7 fallback-profile mechanism). --- @@ -422,7 +422,7 @@ describe that separate runtime. ### Phase 9: Security (Plugin) 🟡 **PHASE-1 LANDED** - [x] Authentication Plugin (`@objectstack/plugin-auth`, better-auth) - [x] Authorization Plugin — `@objectstack/plugin-security` enforces CRUD/FLS/RLS in the ObjectQL middleware chain; REST → ObjectQL now propagates `ExecutionContext` end-to-end (Phase-1) -- [x] Row-Level Security — default `member_default` permission set applies a wildcard `organization_id == current_user.organization_id` rule plus per-object overrides `sys_organization_self` / `sys_user_self`. The earlier `tenantField` rewrite indirection was removed: RLS column, placeholder, and `RLSUserContext.organization_id` use the same canonical name end-to-end +- [x] Row-Level Security — tenant isolation is a Layer 0 tenant wall (`plugin-security/tenant-layer.ts`, ADR-0095 D1) that AND-composes `organization_id == current_user.organization_id` independently of business RLS (the earlier wildcard `tenant_isolation` policy on `member_default` was retired); `member_default` still applies per-object overrides `sys_organization_self` / `sys_user_self`. The earlier `tenantField` rewrite indirection was removed: RLS column, placeholder, and `RLSUserContext.organization_id` use the same canonical name end-to-end - [x] Analytics RLS bridge — `@objectstack/service-analytics` auto-bridges to `security.getReadFilter(object, context)` and fails closed when read-scope resolution cannot be safely applied - [x] Multi-tenancy — verified cross-organization isolation on `pnpm dev:crm` (Alice@OrgAlpha vs. Bob@OrgBeta only see their own records across `sys_organization`, `sys_member`, `sys_user`, and `sys_*_permission_set` link tables) - [x] Legacy `objectql.registerTenantMiddleware` removed — SecurityPlugin is now the sole tenant-isolation authority diff --git a/content/docs/releases/v12.mdx b/content/docs/releases/v12.mdx index b2b0d6a1a7..97934e6cf1 100644 --- a/content/docs/releases/v12.mdx +++ b/content/docs/releases/v12.mdx @@ -52,9 +52,12 @@ ignored. `requireAuth: false` for stacks whose tier set has no `auth` (nothing could authenticate against them), with the boot warning. -> Scope note: this gate covers the REST `/data/*` surface. The metadata -> read/write endpoints and the dispatcher GraphQL route have their own -> pre-existing anonymous posture, tracked separately. +> Scope note: the deny posture applies uniformly to every HTTP surface that +> reaches object data (#2567) — the REST `/data/*` CRUD + batch routes, the +> `/meta` metadata endpoints, the dispatcher's GraphQL endpoint, and the +> raw-hono `/data` routes share one decision (`shouldDenyAnonymous` in +> `@objectstack/core`), so a caller denied on `/data` cannot read the same rows +> through a sibling door. ## New in the framework @@ -163,9 +166,10 @@ validation rejects mappings targeting undefined objects at build time. blank form, a silently no-op submit). Silent `._2` rename collisions are surfaced as warnings. Shifts objectui's runtime `viewKind` guard left to compile time. -- **`validateListViewMode`** (ADR-0053 phase 4) — `userFilters` on an object - list view is now a `tsc` error via `ObjectListViewSchema`, stripped at parse - for back-compat, and reported pre-parse with a fix hint. +- **`validateListViewMode`** (ADR-0047) — a `tabs`-style `userFilters` + (`element: 'tabs'`) on an object list view is now a `tsc` error via + `ObjectListViewSchema` (dropdown value chips remain allowed), stripped at + parse for back-compat, and reported pre-parse with a fix hint. - **`transfer` / `restore` / `purge` pre-mapped to RBAC bits** (#1883) — the permission evaluator maps the destructive lifecycle operations to `allowTransfer` / `allowRestore` / `allowPurge` (with the `modifyAllRecords` diff --git a/content/docs/releases/v13.mdx b/content/docs/releases/v13.mdx index 2da82af5ca..1fc7525665 100644 --- a/content/docs/releases/v13.mdx +++ b/content/docs/releases/v13.mdx @@ -55,7 +55,7 @@ spec shape. If you relied on the implicit public default, declare it explicitly: ```ts -defineObject({ +ObjectSchema.create({ name: 'crm_lead', sharingModel: 'public_read_write', // make the previous behavior explicit // … @@ -200,8 +200,9 @@ read by no runtime path and are removed: reads the per-policy `RowLevelSecurityPolicySchema`, never the global config. Kept `[EXPERIMENTAL]`: `EncryptionConfigSchema` (at-rest field encryption). -Marked `[EXPERIMENTAL — NOT ENFORCED]`: `AgentSchema.visibility` — it does not -hide an agent; use `access` / `permissions` for real gating. +**Removed** (not merely marked): `AgentSchema.visibility` — no runtime consumer +ever read it, so `private` never hid an agent (#1901); use `access` / +`permissions` for real gating, both enforced at the chat route. ## Notable fixes diff --git a/content/docs/releases/v14.mdx b/content/docs/releases/v14.mdx index dbc96d1d9b..c8d92011c3 100644 --- a/content/docs/releases/v14.mdx +++ b/content/docs/releases/v14.mdx @@ -32,9 +32,9 @@ is a **capability reference**, a permission-set name the reader must hold: ```ts // before -book({ name: 'crm_admin_guide', audience: { profile: 'crm_admin' } }) +defineBook({ name: 'crm_admin_guide', audience: { profile: 'crm_admin' } }) // after -book({ name: 'crm_admin_guide', audience: { permissionSet: 'crm_admin' } }) +defineBook({ name: 'crm_admin_guide', audience: { permissionSet: 'crm_admin' } }) ``` Pre-launch one-step rename, no alias — the zod union rejects `{ profile }` at @@ -88,9 +88,9 @@ defined enforcement contract — `ObjectCapabilities` is **100% live**: ### Approvals route to positions (14.0.0, ADR-0090 D3) A new `position` approver type — `{ type: 'position', value: '' }` expands -to the position's holders via `sys_user_position`. Keep `type: 'role'` only for -membership tiers (owner/admin/member); the `department` approver type is now -honored by its spec spelling. New lint rules (`approval-role-not-membership-tier`, +to the position's holders via `sys_user_position`. Keep `type: 'org_membership_level'` +only for membership tiers (owner/admin/member); the `department` approver type is now +honored by its spec spelling. New lint rules (`approval-approver-not-membership-tier`, `approval-approver-type-unknown`, `approval-escalation-reassign-no-target`) catch the mismatches, and SLA escalation `escalateTo` is documented position-first (14.1.0). @@ -119,7 +119,7 @@ capabilities land: The platform had no SMS delivery; this adds it end to end: -- **New `@objectstack/plugin-sms`** — `ISmsService` / `ISmsTransport` contracts +- **New `@objectstack/service-sms`** — `ISmsService` / `ISmsTransport` contracts with Aliyun SMS and Twilio transports plus a dev log fallback, configured through a new `sms` settings namespace. Deliberately no message persistence and no body logging (SMS bodies carry OTP codes). diff --git a/content/docs/releases/v15.mdx b/content/docs/releases/v15.mdx index 267c455765..6cbc6860d8 100644 --- a/content/docs/releases/v15.mdx +++ b/content/docs/releases/v15.mdx @@ -456,7 +456,7 @@ search guide with `sys_user`/picker coverage and existing-row backfill notes predicate over `record` + `current_user` + `page.`; FALSE removes the whole tab (header and panel), re-evaluated live as page variables change. The new surface deliberately accepts no legacy `visibility`/`visibleOn` aliases. -- **View property liveness (#3046, #2998 Track B):** all 83 view properties +- **View property liveness (#3046, #2998 Track B):** all 75 view properties classified (68 live / 2 experimental / 5 dead); `os build` warns at compile time on dead props (e.g. a `chart` list view that renders nothing) with fix-it hints. diff --git a/content/docs/releases/v9.mdx b/content/docs/releases/v9.mdx index 74393c7b90..7f39d08b7c 100644 --- a/content/docs/releases/v9.mdx +++ b/content/docs/releases/v9.mdx @@ -145,11 +145,20 @@ Authentication** or via deployment-level `GOOGLE_CLIENT_ID` / ### CLI: AI provider SDKs bundled -The CLI now ships `@ai-sdk/openai`, `@ai-sdk/anthropic`, and `@ai-sdk/google` -as direct dependencies. A globally-installed CLI can configure any -OpenAI-compatible provider (DeepSeek, DashScope, SiliconFlow, OpenRouter, -Cloudflare) without the previous "Could not build adapter for provider=…" -failure. +The CLI ships `@ai-sdk/openai`, `@ai-sdk/anthropic`, and `@ai-sdk/google` as +direct dependencies. These were previously only optional peer dependencies of +`@objectstack/service-ai`, so a globally-installed CLI couldn't resolve them at +runtime — configuring an OpenAI-compatible provider (DeepSeek, DashScope, +SiliconFlow, OpenRouter, Cloudflare, which all normalize to `provider=openai` +and dynamically import `@ai-sdk/openai`) failed with "Could not build adapter +for provider=…". Bundling them makes those providers work out of the box. + +> Note: later releases moved the provider SDKs back to a host-install model — in +> the current CLI they are not bundled. The `ai` settings namespace states that +> provider SDK packages (e.g. `@ai-sdk/openai`) must be installed on the host for +> the chosen provider to load, and the AI runtime runs through the host-declared +> `@objectstack/service-ai` plugin, which auto-detects the provider from +> environment variables (`AI_GATEWAY_MODEL`, `OPENAI_API_KEY`, …). ## New in Console (Studio) diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx index c373e03419..b65343a727 100644 --- a/content/docs/ui/actions.mdx +++ b/content/docs/ui/actions.mdx @@ -25,7 +25,7 @@ The types you'll actually use: The schema also accepts `api` and `form` types, but they have **no runtime executor / renderer today** — stick to the four above. Likewise prefer `confirmText`/`params` over schema properties that are not yet wired -(`shortcut`, `bulkEnabled`, `timeout`). +(`shortcut`, `bulkEnabled`). ## Define your first action @@ -186,9 +186,10 @@ engine still requires the action to declare the matching location (that's why Unset means no gate beyond object CRUD permissions. Referenced capabilities must exist — `os lint` checks that. - **`visible`** is a CEL predicate evaluated **fail-closed**: an expression - that throws hides the action silently. Two rules save real debugging time: - always prefix record fields (`record.status != "closed"`, never a bare - `status`), and keep it to a single operand — see the + that throws hides the action silently. The rule that saves real debugging + time: always prefix record fields (`record.status != "closed"`, never a bare + `status`, which faults as an undeclared identifier). Compound `&&` / `||` + predicates are fully supported — see the [formulas guide](/docs/data-modeling/formulas) for CEL syntax. - **`requiresFeature`** ties visibility to a feature flag (compiled into a `visible` predicate). @@ -230,7 +231,7 @@ and [Connect an MCP Client](/docs/ai/connect-mcp). | Symptom | Cause → fix | |:---|:---| -| Button doesn't appear | `locations` doesn't include the surface; or the `visible` CEL throws (bare field name, multiple operands) and fail-closed hides it; or the user fails `requiredPermissions` | +| Button doesn't appear | `locations` doesn't include the surface; or the `visible` CEL throws (e.g. a bare, unprefixed field name) and fail-closed hides it; or the user fails `requiredPermissions` | | Click → `Action 'x' … not found` | `target`-style script with no registered handler — add the `registerAction` call in `onEnable` (Path B above) | | Appears in UI, missing from `list_actions` (MCP) | `ai.exposed` not `true`, `ai.description` under 40 characters, or the type isn't headless-callable | | Runs but the list looks stale | Add `refreshAfter: true` | diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index 39e1b1eba3..1f43edb79f 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -66,7 +66,7 @@ Links to an object's list view: { id: 'nav_accounts', type: 'object', label: 'Accounts', objectName: 'account', icon: 'building', viewName: 'all_accounts' } ``` -Three optional target fields refine where the entry lands (precedence: `recordId` → `filters` → `viewName`): +Three optional target fields refine where the entry lands. They are **mutually exclusive** — combining `filters` with `recordId` or `viewName` is rejected at validation, and the only tolerated pairing is the legacy `recordId` + `viewName` (where `recordId` wins and `viewName` is ignored): - `viewName` — anchor the entry to a named list view. - `recordId` — deep-link straight to one record ("My Profile"); supports `{current_user_id}` / `{current_org_id}` template variables. diff --git a/content/docs/ui/audience-based-interfaces.mdx b/content/docs/ui/audience-based-interfaces.mdx index 66b24a34cd..d2f3fd6075 100644 --- a/content/docs/ui/audience-based-interfaces.mdx +++ b/content/docs/ui/audience-based-interfaces.mdx @@ -18,7 +18,7 @@ description: The same data serves different audiences. Give end users a curated | **End user (consumer)** | a curated **App** → `page` / `view` (interface mode) | `App.requiredPermissions`, permission-set `tabPermissions`, nav-item gating | | **Builder / admin** | **Setup / Studio**, raw object tables (data mode) | capabilities: `setup.access`, `studio.access`, `manage_metadata` | -The built-in permission sets already encode this split: `member_default` and `viewer_readonly` do **not** carry `studio.access` / `manage_metadata`, so Studio and schema-design surfaces are invisible to them; `admin_full_access` and `organization_admin` do (see [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts)). +The built-in permission sets already encode this split: `member_default` and `viewer_readonly` do **not** carry `studio.access` / `manage_metadata`, so Studio and schema-design surfaces are invisible to them; `admin_full_access` carries both, while `organization_admin` gets `setup.access` only — the Setup shell is reachable but Studio and schema design stay hidden from org-scoped admins (see [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts)). ### 1. Gate the app and its navigation @@ -60,7 +60,7 @@ This is the lesson from Airtable's surfaces: the **Automations tab is visible to ## Runnable example - Permission sets: [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts) — `member_default` / `viewer_readonly` vs `organization_admin` / `admin_full_access`. -- Apps: [`examples/app-showcase/src/apps`](https://github.com/objectstack-ai/framework/tree/main/examples/app-showcase/src/apps). +- Apps: [`examples/app-showcase/src/ui/apps`](https://github.com/objectstack-ai/framework/tree/main/examples/app-showcase/src/ui/apps). ## Anti-patterns diff --git a/content/docs/ui/create-vs-edit-form.mdx b/content/docs/ui/create-vs-edit-form.mdx index 164abc3154..36bc565808 100644 --- a/content/docs/ui/create-vs-edit-form.mdx +++ b/content/docs/ui/create-vs-edit-form.mdx @@ -101,8 +101,8 @@ Rule of thumb: **"different field subset" → derive. "different layout or flow" ## Runnable example -- Object: [`examples/app-showcase/src/objects/contact.object.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/objects/contact.object.ts) — flat, grouped, intent-tagged field set. -- Views: [`examples/app-showcase/src/views/contact.view.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/views/contact.view.ts) — full edit form + sparse `formViews.create` + `addRecord` binding. +- Object: [`examples/app-showcase/src/data/objects/contact.object.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/data/objects/contact.object.ts) — flat, grouped, intent-tagged field set. +- Views: [`examples/app-showcase/src/ui/views/contact.view.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/ui/views/contact.view.ts) — full edit form + sparse `formViews.create` + `addRecord` binding. ## Anti-patterns diff --git a/content/docs/ui/dashboards.mdx b/content/docs/ui/dashboards.mdx index e5dd275746..c02eb7e7b4 100644 --- a/content/docs/ui/dashboards.mdx +++ b/content/docs/ui/dashboards.mdx @@ -11,7 +11,7 @@ A **Dashboard** defines an analytics page with chart widgets, key metrics, and d Every widget binds to a **dataset** (the semantic layer, ADR-0021) and selects the dataset's `dimensions` and `values` by name. The dataset owns the base -object, joins, and certified measures, so the same numbers stay consistent +object, joins, and measures, so the same numbers stay consistent across every dashboard and report. ```typescript @@ -111,7 +111,7 @@ selects `dimensions` (X / group / split) and `values` (the measures to plot): Every widget binds to a **dataset** — the single semantic layer (ADR-0021). A dataset defines the base object, allowed relationship joins, reusable -dimensions, certified measures, and intrinsic filters once; dashboards and +dimensions, measures, and intrinsic filters once; dashboards and reports then reference the same names so "revenue", "pipeline", or "win rate" stay consistent across surfaces. @@ -132,7 +132,7 @@ export const salesDataset = defineDataset({ { name: 'close_month', field: 'close_date', type: 'date', dateGranularity: 'month' }, ], measures: [ - { name: 'revenue', field: 'amount', aggregate: 'sum', certified: true }, + { name: 'revenue', field: 'amount', aggregate: 'sum' }, { name: 'deal_count', aggregate: 'count' }, ], }); diff --git a/content/docs/ui/doc-pages.mdx b/content/docs/ui/doc-pages.mdx index 90c55c807b..51e4852baf 100644 --- a/content/docs/ui/doc-pages.mdx +++ b/content/docs/ui/doc-pages.mdx @@ -78,10 +78,10 @@ src/docs/user_guide.md ❌ bare name — lint error (docs/namespace-pr This is the same `namespace-prefix` lint the platform applies to other named metadata for readable, globally-unique filenames. It is no longer **load-bearing for uniqueness**, however: per **ADR-0048**, doc -resolution is **package-scoped**. The metadata registry key carries no -package coordinate, but each item retains its `_packageId`, so two -installed packages may each ship a doc with the same bare name and -coexist — neither silently overwrites the other. +resolution is **package-scoped**. Packaged items are stored under a +composite `:` registry key, and each retains its +`_packageId`, so two installed packages may each ship a doc with the same +bare name and coexist — neither silently overwrites the other. > **Resolution model.** Doc resolution is package-scoped (ADR-0048). The > single-doc detail route resolves a name within a package id — diff --git a/content/docs/ui/field-grouping-and-order.mdx b/content/docs/ui/field-grouping-and-order.mdx index e496ea7e4e..841a38aed9 100644 --- a/content/docs/ui/field-grouping-and-order.mdx +++ b/content/docs/ui/field-grouping-and-order.mdx @@ -53,7 +53,7 @@ There is **no `field.order`**. The order you declare fields in the object **is** ## The trap: a table's "group" is a *different thing* -A grid view also has `grouping` / `groupByField` — but that groups **records (rows)** by a field's **value** (all `stage = qualified` rows together), not **fields (columns)** into sections. The word "group" means two unrelated axes: +A grid view also has `grouping` (and a board/kanban view, `groupByField`) — but that groups **records (rows)** by a field's **value** (all `stage = qualified` rows together), not **fields (columns)** into sections. The word "group" means two unrelated axes: | Surface | "group" means | Axis | |---|---|---| @@ -68,8 +68,8 @@ Keeping grouping off the field (beyond an optional semantic hint) is what lets * ## Runnable example -- [`examples/app-showcase/src/objects/contact.object.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/objects/contact.object.ts) — fields tagged with `group`. -- [`examples/app-showcase/src/views/contact.view.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/views/contact.view.ts) — sections that materialise those groups. +- [`examples/app-showcase/src/data/objects/contact.object.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/data/objects/contact.object.ts) — fields tagged with `group`. +- [`examples/app-showcase/src/ui/views/contact.view.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/ui/views/contact.view.ts) — sections that materialise those groups. ## Anti-patterns diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index f73991a288..084eb429e9 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -56,9 +56,8 @@ Only the spec'd whitelist of form fields is accepted; everything else (status, o import { defineView } from '@objectstack/spec'; export default defineView({ - name: 'lead_views', - object: 'lead', - /* ... internal listViews etc. ... */ + /* ...default `list` / named `listViews` for `lead` live here too — the + container's target object is derived from a view's `data.object`... */ formViews: { web_to_lead: { type: 'simple', @@ -263,11 +262,10 @@ Sometimes you want the same FormView metadata to power an authed operator flow ```ts // hotcrm/src/views/lead.view.ts (internal form variant) export default defineView({ - name: 'lead_views', - object: 'lead', formViews: { quick_create: { type: 'simple', + data: { provider: 'object', object: 'lead' }, sections: [ { label: 'Lead', diff --git a/content/docs/ui/index.mdx b/content/docs/ui/index.mdx index 12adc5fbc0..67236d06a1 100644 --- a/content/docs/ui/index.mdx +++ b/content/docs/ui/index.mdx @@ -31,7 +31,7 @@ export const CrmApp = App.create({ - **Apps** group navigation, branding, and entry points for one audience. - **Views** present object records as `grid` (the standard data table), `kanban`, `gallery`, `calendar`, `timeline`, `gantt`, `map`, `chart`, or `tree`. Forms are their own view kind with per-mode layouts ([Views](/docs/ui/views)). - **Pages** compose free-form layouts from widgets; **Dashboards** combine charts, reports, and datasets for analytics. -- **Themes** define palettes, typography, and spacing as metadata — the CRM example ships light and dark theme variants. +- **Themes** define palettes, typography, and spacing as metadata — the showcase example ships light and dark theme variants. - **Portals** open a scoped slice of the app to external audiences, including **anonymous entry routes** for public data collection ([Forms](/docs/ui/forms), [public data collection](/docs/ui/public-data-collection)). - The **Setup App** — the platform's built-in administration UI — is itself rendered from the same protocol ([Setup App](/docs/ui/setup-app)). diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 45c0dd189d..1b9efdf520 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -122,7 +122,7 @@ Components are the building blocks placed inside regions. events: { onClick: "navigate_to('opportunity_detail', { id: $event.id })", }, - visibility: "os.user.profile == 'sales_manager'", + visibleWhen: "'sales_manager' in current_user.positions", style: { height: '400px' }, } ``` @@ -137,7 +137,7 @@ Components are the building blocks placed inside regions. | `style` | `object` | optional | CSS styles | | `className` | `string` | optional | CSS class names | | `responsiveStyles` | `object` | optional | **Preferred** SDUI styling channel (ADR-0065): **desktop-first** per-breakpoint scoped style maps, compiled to id-scoped CSS at render. Keys are `large` (the unconditional base), then `medium` / `small` / `xsmall` as `max-width` overrides. Prefer design tokens, e.g. `{ large: { padding: 'var(--space-8)' }, small: { padding: 'var(--space-4)' } }`. Use over ad-hoc `style` / `className` for metadata-authored pages. | -| `visibility` | `string` | optional | Visibility predicate (CEL expression) | +| `visibleWhen` | `string` | optional | Visibility predicate (CEL) — component renders only when the expression is `true`. Binds `record`, `current_user`, and page state as `page.`. (Legacy alias `visibility` is accepted but deprecated per ADR-0089.) | The `type` field is a union of the standard `PageComponentType` enum and any custom string. The standard (namespaced) component types include: diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index 3781fcf2b3..0567f307ad 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -57,8 +57,8 @@ Authorization is **derived from the declaration**, not configured separately — ## Runnable example -- Object: [`examples/app-showcase/src/objects/inquiry.object.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/objects/inquiry.object.ts). -- Public form: [`examples/app-showcase/src/views/inquiry.view.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/views/inquiry.view.ts). +- Object: [`examples/app-showcase/src/data/objects/inquiry.object.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/data/objects/inquiry.object.ts). +- Public form: [`examples/app-showcase/src/ui/views/inquiry.view.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/ui/views/inquiry.view.ts). ## Anti-patterns diff --git a/content/docs/ui/setup-app.mdx b/content/docs/ui/setup-app.mdx index 27b7b1bd43..16eadb95b4 100644 --- a/content/docs/ui/setup-app.mdx +++ b/content/docs/ui/setup-app.mdx @@ -49,11 +49,11 @@ anchors are: | Group (anchor id) | Filled by | |:---|:---| | **Overview** (`group_overview`) | System Overview dashboard — `platform-objects` | -| **Apps** (`group_apps`) | Capability plugins only (e.g. marketplace via cloud-connection); empty otherwise | -| **People & Organization** (`group_people_org`) | Users · Departments · Teams · Organizations · Invitations — `platform-objects` | +| **Apps** (`group_apps`) | Packages — `platform-objects`; marketplace — `cloud-connection` (capability plugin) | +| **People & Organization** (`group_people_org`) | Users · Organization · Business Units · Teams · Organizations · Invitations — `platform-objects` | | **Access Control** (`group_access_control`) | Positions / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` | | **Approvals** (`group_approvals`) | `plugin-approvals` | -| **Configuration** (`group_configuration`) | All Settings · Branding · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects` | +| **Configuration** (`group_configuration`) | All Settings · Localization · Company · Branding · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects` | | **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Logs — `plugin-audit` | | **Integrations** (`group_integrations`) | `plugin-webhooks` | | **Advanced** (`group_advanced`) | OAuth Applications · Signing Keys (JWKS) · Identity Links · User Preferences — `platform-objects` | diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 9d0d31c13c..92bd5c85d8 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -74,8 +74,8 @@ export default defineStack({ | Dashboards and widgets | `dashboards.` | | Global actions, settings, messages | `globalActions`, `settings`, `messages` | -The metadata types resolved per request are **object, field, view, action, app, -and dashboard**. +The metadata types resolved per request are **object, view, action, app, and +dashboard** — a field's labels are translated as part of its object document. ## How a locale is chosen diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index 468c080c78..2e793f33cb 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -433,6 +433,103 @@ function generateZodFileMarkdown(zodFile: string, schemas: Array<{name: string, return md; } +/** + * Reference-sidebar section grouping, per category. #1862 grouped each category's + * `references/{cat}/meta.json` by hand, but `gen:docs` rewrites those files as a flat + * alphabetical list on every run (in the docs production build), so the grouping was + * silently lost. Owning it here makes it durable (#1880). + * + * A page is placed under the FIRST section that names it. The mapping degrades + * gracefully: names that produce no page are ignored, and pages present on disk but + * not named here still appear (sorted, under a trailing `More` separator) — so adding + * or removing a schema never drops it from the sidebar. Categories with no entry keep + * the legacy flat sorted list. + */ +const SECTION_GROUPS: Record> = { + ai: [ + { section: 'Agents & Tools', pages: ['agent', 'tool', 'skill', 'solution-blueprint', 'mcp'] }, + { section: 'Knowledge & RAG', pages: ['knowledge-document', 'knowledge-source', 'embedding'] }, + { section: 'Models & Runtime', pages: ['model-registry', 'conversation', 'usage'] }, + ], + api: [ + { section: 'Contract & Routing', pages: ['protocol', 'contract', 'endpoint', 'router', 'registry', 'discovery', 'documentation', 'versioning', 'errors', 'batch'] }, + { section: 'Transport & Realtime', pages: ['http', 'http-cache', 'rest-server', 'websocket', 'realtime', 'realtime-shared', 'graphql', 'odata', 'query-adapter', 'dispatcher'] }, + { section: 'Service APIs', pages: ['core-services', 'auth', 'auth-endpoints', 'identity', 'metadata', 'metadata-plugin', 'automation-api', 'analytics', 'export', 'storage', 'notification', 'events', 'connector', 'package-api', 'package-registry', 'plugin-rest-api'] }, + ], + automation: [ + { section: 'Flow & Execution', pages: ['flow', 'control-flow', 'execution', 'node-executor', 'state-machine', 'trigger-registry', 'time-relative-trigger'] }, + { section: 'Integration & Data', pages: ['sync', 'etl', 'connector', 'webhook', 'bpmn-interop', 'offline'] }, + { section: 'Approvals & Jobs', pages: ['approval', 'job'] }, + ], + cloud: [ + { section: 'Environments & Packages', pages: ['environment', 'environment-artifact', 'environment-package', 'package', 'package-version', 'template-manifest', 'provisioning'] }, + { section: 'Marketplace & Distribution', pages: ['marketplace', 'marketplace-admin', 'app-store', 'developer-portal'] }, + { section: 'Tenancy & Security', pages: ['tenant', 'plugin-security'] }, + ], + data: [ + { section: 'Objects & Fields', pages: ['object', 'field', 'validation', 'hook', 'hook-body', 'mapping'] }, + { section: 'Query & Analytics', pages: ['query', 'filter', 'data-engine', 'analytics', 'date-macros'] }, + { section: 'Datasources & Drivers', pages: ['datasource', 'driver', 'driver-sql', 'driver-nosql', 'external-catalog', 'external-lookup'] }, + { section: 'Documents & Seed', pages: ['document', 'seed', 'seed-loader', 'feed'] }, + ], + integration: [ + { section: 'Connectors', pages: ['connector', 'connector-auth', 'mapping', 'translation'] }, + { section: 'Transport & Storage', pages: ['http', 'message-queue', 'object-storage', 'offline'] }, + { section: 'Tenancy', pages: ['tenant', 'misc'] }, + ], + kernel: [ + { section: 'Plugin Lifecycle', pages: ['plugin', 'plugin-lifecycle-events', 'plugin-lifecycle-advanced', 'plugin-runtime', 'plugin-loading', 'plugin-registry', 'plugin-structure', 'plugin-validator'] }, + { section: 'Plugin Security & Dependencies', pages: ['plugin-security', 'plugin-security-advanced', 'plugin-capability', 'plugin-versioning', 'dependency-resolution', 'manifest'] }, + { section: 'Packages', pages: ['package-artifact', 'package-registry', 'package-upgrade'] }, + { section: 'Metadata & Runtime', pages: ['metadata-plugin', 'metadata-loader', 'metadata-customization', 'metadata-protection', 'metadata-persistence', 'misc', 'context', 'execution-context', 'service-registry', 'startup-orchestrator', 'cluster', 'feature', 'cli-extension', 'dev-plugin', 'state-machine'] }, + ], + system: [ + { section: 'Config & Settings', pages: ['settings-manifest', 'settings-client', 'registry-config', 'auth-config', 'email-config', 'email-template', 'license', 'migration', 'deploy-bundle', 'environment-artifact', 'app-install', 'provisioning', 'tenant'] }, + { section: 'Services & Infrastructure', pages: ['core-services', 'http-server', 'cache', 'message-queue', 'object-storage', 'search-engine', 'worker', 'job', 'notification', 'translation', 'metadata-loader', 'metadata-persistence'] }, + { section: 'Observability', pages: ['logging', 'metrics', 'tracing', 'audit'] }, + { section: 'Security & Compliance', pages: ['encryption', 'security-context', 'incident-response', 'supplier-security', 'disaster-recovery', 'change-management', 'training'] }, + { section: 'Content & Collaboration', pages: ['doc', 'book', 'collaboration'] }, + ], + ui: [ + { section: 'Apps & Navigation', pages: ['app', 'page', 'portal', 'view', 'action'] }, + { section: 'Visualization', pages: ['chart', 'dashboard', 'dataset', 'report', 'widget', 'component'] }, + { section: 'Interaction & Layout', pages: ['animation', 'dnd', 'keyboard', 'touch', 'responsive', 'theme', 'offline'] }, + { section: 'Platform', pages: ['i18n', 'notification', 'sharing', 'http'] }, + ], +}; + +/** + * Build a category's `meta.json` `pages` array. With a SECTION_GROUPS entry, emit + * fumadocs `"---Section---"` separators around the grouped pages; otherwise fall back + * to the legacy flat alphabetical list. `emitted` is the set of pages that actually + * produced a reference file this run. + */ +function buildCategoryPages(category: string, emitted: string[]): string[] { + const groups = SECTION_GROUPS[category]; + if (!groups) return [...emitted].sort(); + + const present = new Set(emitted); + const used = new Set(); + const out: string[] = []; + + for (const { section, pages } of groups) { + const inSection = pages.filter(p => present.has(p) && !used.has(p)).sort(); + if (inSection.length === 0) continue; + out.push(`---${section}---`); + for (const p of inSection) { out.push(p); used.add(p); } + } + + // Pages on disk that no section claimed still appear — never drop a page. Only + // label them when something above was grouped; a lone "More" over an otherwise + // ungrouped category would read oddly, so degrade to a flat list in that case. + const leftover = emitted.filter(p => !used.has(p)).sort(); + if (leftover.length) { + if (out.length) out.push('---More---'); + out.push(...leftover); + } + return out; +} + // === EXECUTION === console.log('Building documentation...'); @@ -501,10 +598,11 @@ Object.keys(CATEGORIES).forEach(category => { emit(path.join(categoryDir, fileName), mdx); }); - // Generate Category Meta + // Generate Category Meta. Group into fumadocs `---Section---` separators when the + // category has a SECTION_GROUPS entry; otherwise a flat sorted list (see #1880). const meta = { title: CATEGORIES[category], - pages: Array.from(zodFileSchemas.keys()).sort() + pages: buildCategoryPages(category, Array.from(zodFileSchemas.keys())) }; emit(path.join(categoryDir, 'meta.json'), JSON.stringify(meta, null, 2)); }); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 43bdf010a4..8bbed71fc1 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -376,10 +376,10 @@ export const ListChartConfigSchema = lazySchema(() => z.object({ * Calendar Settings */ export const CalendarConfigSchema = lazySchema(() => z.object({ - startDateField: z.string(), - endDateField: z.string().optional(), - titleField: z.string(), - colorField: z.string().optional(), + startDateField: z.string().describe('Field providing the event start date/time'), + endDateField: z.string().optional().describe('Field providing the event end date/time (defaults to a single-day event)'), + titleField: z.string().describe('Field displayed as the event title'), + colorField: z.string().optional().describe('Field whose value determines the event color'), })); /** @@ -405,11 +405,11 @@ export const GanttQuickFilterSchema = lazySchema(() => z.object({ * dynamic grouping, a resource/workload view, hover tooltips and quick filters. */ export const GanttConfigSchema = lazySchema(() => z.object({ - startDateField: z.string(), - endDateField: z.string(), - titleField: z.string(), - progressField: z.string().optional(), - dependenciesField: z.string().optional(), + startDateField: z.string().describe('Field providing the task start date'), + endDateField: z.string().describe('Field providing the task end date'), + titleField: z.string().describe('Field displayed as the task title'), + progressField: z.string().optional().describe('Field providing the task completion percentage'), + dependenciesField: z.string().optional().describe("Field listing the task's predecessor (dependency) record ids"), colorField: z.string().optional().describe('Field that drives the bar color'), // Two-level hierarchy: a parent task id (summary bar) and a row type. parentField: z.string().optional().describe('Field holding the parent task id (builds the summary → step tree)'), @@ -583,13 +583,13 @@ export const ListViewSchema = lazySchema(() => z.object({ pagination: PaginationConfigSchema.optional().describe('Pagination configuration'), /** Type Specific Config */ - kanban: KanbanConfigSchema.optional(), - calendar: CalendarConfigSchema.optional(), - gantt: GanttConfigSchema.optional(), + kanban: KanbanConfigSchema.optional().describe('Kanban-board configuration — applies when the view renders as a kanban layout'), + calendar: CalendarConfigSchema.optional().describe('Calendar configuration — applies when the view renders as a calendar layout'), + gantt: GanttConfigSchema.optional().describe('Gantt-timeline configuration — applies when the view renders as a gantt layout'), gallery: GalleryConfigSchema.optional(), timeline: TimelineConfigSchema.optional(), chart: ListChartConfigSchema.optional(), - tree: TreeConfigSchema.optional(), + tree: TreeConfigSchema.optional().describe('Tree/hierarchy configuration — applies when the view renders as a tree layout'), /** View Metadata (Airtable-style view management) */ description: I18nLabelSchema.optional().describe('View description for documentation/tooltips'), diff --git a/scripts/role-word-baseline.json b/scripts/role-word-baseline.json index 5d11bb70ea..d63734da32 100644 --- a/scripts/role-word-baseline.json +++ b/scripts/role-word-baseline.json @@ -2,24 +2,17 @@ "content/docs/ai/agents.mdx": 5, "content/docs/ai/index.mdx": 2, "content/docs/api/error-catalog.mdx": 1, - "content/docs/api/wire-format.mdx": 1, "content/docs/automation/approvals.mdx": 1, - "content/docs/automation/hooks.mdx": 1, "content/docs/concepts/architecture.mdx": 4, - "content/docs/concepts/metadata-lifecycle.mdx": 1, "content/docs/data-modeling/seed-data.mdx": 2, "content/docs/deployment/migration-from-objectql.mdx": 2, "content/docs/getting-started/cli.mdx": 1, "content/docs/getting-started/common-patterns.mdx": 1, "content/docs/getting-started/index.mdx": 1, "content/docs/getting-started/quick-start.mdx": 1, - "content/docs/kernel/contracts/auth-service.mdx": 4, "content/docs/kernel/contracts/data-engine.mdx": 1, - "content/docs/kernel/contracts/metadata-service.mdx": 1, "content/docs/kernel/events.mdx": 1, - "content/docs/kernel/runtime-services/examples.mdx": 2, - "content/docs/kernel/runtime-services/sharing-service.mdx": 2, - "content/docs/kernel/services-checklist.mdx": 5, + "content/docs/kernel/services-checklist.mdx": 4, "content/docs/permissions/authentication.mdx": 2, "content/docs/permissions/authorization.mdx": 3, "content/docs/permissions/index.mdx": 1, @@ -27,7 +20,7 @@ "content/docs/permissions/permission-sets.mdx": 1, "content/docs/permissions/positions.mdx": 6, "content/docs/plugins/anatomy.mdx": 7, - "content/docs/plugins/packages.mdx": 2, + "content/docs/plugins/packages.mdx": 1, "content/docs/protocol/diagram.mdx": 4, "content/docs/protocol/kernel/error-handling.mdx": 1, "content/docs/protocol/kernel/index.mdx": 1, @@ -38,7 +31,7 @@ "content/docs/releases/implementation-status.mdx": 1, "content/docs/releases/index.mdx": 1, "content/docs/releases/v13.mdx": 15, - "content/docs/releases/v14.mdx": 7, + "content/docs/releases/v14.mdx": 5, "content/docs/ui/forms.mdx": 3, "skills/objectstack-ai/SKILL.md": 8, "skills/objectstack-api/SKILL.md": 1,