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 `
## 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,