diff --git a/content/docs/concepts/architecture.mdx b/content/docs/concepts/architecture.mdx
index c845706bed..5b930d9cd4 100644
--- a/content/docs/concepts/architecture.mdx
+++ b/content/docs/concepts/architecture.mdx
@@ -143,65 +143,65 @@ That's the job of the other layers.
### Example: Permission Rules
```typescript
-// packages/crm/src/permissions/customer.permission.ts
-import { Permission } from '@objectstack/spec';
-
-export const CustomerPermission = Permission({
- object: 'customer',
- rules: [
- {
- profile: 'sales_rep',
- crud: {
- create: true,
- read: true,
- update: true,
- delete: false, // Only managers can delete
- },
- fieldPermissions: {
- annual_revenue: { read: true, edit: false }, // Read-only
- },
+// packages/crm/src/permissions/sales_rep.permission.ts
+import type { PermissionSet } from '@objectstack/spec/security';
+
+// A permission set is keyed by object name. Each object grants the four
+// CRUD verbs (allowCreate / allowRead / allowEdit / allowDelete), and
+// optional field-level security keyed by "object.field".
+export const SalesRep: PermissionSet = {
+ name: 'sales_rep',
+ isProfile: true,
+ objects: {
+ customer: {
+ allowCreate: true,
+ allowRead: true,
+ allowEdit: true,
+ allowDelete: false, // Only managers can delete
},
- {
- profile: 'sales_manager',
- crud: {
- create: true,
- read: true,
- update: true,
- delete: true,
- },
- },
- ],
-});
+ },
+ fields: {
+ 'customer.annual_revenue': { readable: true, editable: false }, // Read-only
+ },
+};
```
### Example: Workflow Automation
-```typescript
-// packages/crm/src/workflows/customer.workflow.ts
-import { Workflow } from '@objectstack/spec';
+Automations are authored with `defineFlow`. A flow is a **node graph** —
+a typed `start`/`end` plus decision and action nodes wired together by
+`edges` — driven by a trigger `type` (here `record_change`):
-export const CustomerWorkflow = Workflow({
- object: 'customer',
- trigger: 'after_create',
- conditions: [
- { field: 'annual_revenue', operator: 'greaterThan', value: 1000000 },
+```typescript
+// packages/crm/src/flows/high_value_customer.flow.ts
+import { defineFlow } from '@objectstack/spec';
+
+export const HighValueCustomer = defineFlow({
+ name: 'high_value_customer',
+ label: 'High-Value Customer Routing',
+ type: 'record_change', // runs when a customer record changes
+ nodes: [
+ { id: 'start', type: 'start', label: 'Customer created' },
+ { id: 'assign', type: 'action', label: 'Assign to enterprise team' },
+ { id: 'notify', type: 'action', label: 'Alert sales leadership' },
+ { id: 'end', type: 'end', label: 'End' },
],
- actions: [
- {
- type: 'assign_owner',
- params: { owner: 'enterprise_sales_team' },
- },
- {
- type: 'send_email',
- params: {
- template: 'high_value_customer_alert',
- to: 'sales-leadership@company.com',
- },
- },
+ edges: [
+ // Only branch when annual_revenue > 1,000,000 (CEL condition)
+ { id: 'e1', source: 'start', target: 'assign', condition: 'record.annual_revenue > 1000000' },
+ { id: 'e2', source: 'assign', target: 'notify' },
+ { id: 'e3', source: 'notify', target: 'end' },
],
});
```
+
+The node/edge wiring above is illustrative. For the full set of node types,
+trigger semantics, and condition syntax, see the
+[automation skill](/docs/protocol/objectos) and the `defineFlow` schema in
+`@objectstack/spec/automation`.
+
+
ObjectOS **orchestrates** these rules at runtime, independent of the data structure or UI.
## Layer 3: ObjectUI (View Protocol)
@@ -220,63 +220,47 @@ ObjectOS **orchestrates** these rules at runtime, independent of the data struct
### Example: List View
```typescript
-// packages/crm/src/views/customer_list.view.ts
-import { ListView } from '@objectstack/spec';
+// packages/crm/src/views/customer.view.ts
+import { defineView } from '@objectstack/spec';
-export const CustomerListView = ListView({
+// A view is a container bound to an object; its `list` slot holds the
+// list-view config. Columns are field names; sort is { field, order }.
+export const CustomerView = defineView({
object: 'customer',
- label: 'All Customers',
- type: 'grid',
- columns: [
- { field: 'name', width: 200 },
- { field: 'industry', width: 150 },
- { field: 'annual_revenue', width: 150 },
- { field: 'primary_contact', width: 180 },
- ],
- filters: [
- { field: 'industry', operator: 'equals' },
- { field: 'annual_revenue', operator: 'greaterThan' },
- ],
- defaultSort: { field: 'name', direction: 'asc' },
+ list: {
+ label: 'All Customers',
+ type: 'grid',
+ columns: ['name', 'industry', 'annual_revenue', 'primary_contact'],
+ sort: [{ field: 'name', order: 'asc' }],
+ },
});
```
### Example: Form View
```typescript
-// packages/crm/src/views/customer_form.view.ts
-import { FormView } from '@objectstack/spec';
+// packages/crm/src/views/customer.view.ts
+import { defineView } from '@objectstack/spec';
-export const CustomerFormView = FormView({
+// The same view container can also carry a `form` slot. A form is laid out
+// with `sections`, each section listing the fields it renders.
+export const CustomerView = defineView({
object: 'customer',
- label: 'Customer Details',
- type: 'tabbed',
- tabs: [
- {
- label: 'Overview',
- sections: [
- {
- label: 'Company Information',
- fields: ['name', 'industry', 'annual_revenue'],
- },
- {
- label: 'Contact',
- fields: ['primary_contact'],
- },
- ],
- },
- {
- label: 'Related Records',
- sections: [
- {
- label: 'Opportunities',
- component: 'related_list',
- object: 'opportunity',
- filter: { customer: '$recordId' },
- },
- ],
- },
- ],
+ form: {
+ label: 'Customer Details',
+ type: 'tabbed',
+ sections: [
+ {
+ label: 'Company Information',
+ columns: 2,
+ fields: ['name', 'industry', 'annual_revenue'],
+ },
+ {
+ label: 'Contact',
+ fields: ['primary_contact'],
+ },
+ ],
+ },
});
```
@@ -290,6 +274,13 @@ The UI doesn't "know" the field types. It asks ObjectQL for the schema and rende
Let's trace a **real-world scenario**: A sales rep creates a new high-value customer.
+
+The snippets in this walkthrough (`Auth.getCurrentUser`, `Permission.check`,
+`ObjectQL.getSchema`, `driver.insert`, `Workflow.getTriggersFor`, …) are
+**conceptual pseudo-code** that illustrate the control flow between layers.
+They are not literal exported APIs — don't search for these exact symbols.
+
+
### Step 1: User Action (ObjectUI)
```
@@ -431,15 +422,22 @@ export const Opportunity = ObjectSchema.create({
### 2. ObjectOS: Define Business Rules
```typescript
-export const OpportunityWorkflow = Workflow({
- object: 'opportunity',
- trigger: 'field_update',
- conditions: [
- { field: 'stage', operator: 'equals', value: 'closed_won' },
+import { defineFlow } from '@objectstack/spec';
+
+export const OpportunityWon = defineFlow({
+ name: 'opportunity_won',
+ label: 'On Opportunity Won',
+ type: 'record_change',
+ nodes: [
+ { id: 'start', type: 'start', label: 'Stage changed' },
+ { id: 'invoice', type: 'action', label: 'Create invoice' },
+ { id: 'notify', type: 'action', label: 'Notify sales team' },
+ { id: 'end', type: 'end', label: 'End' },
],
- actions: [
- { type: 'create_invoice', params: { object: 'invoice' } },
- { type: 'send_notification', params: { to: 'sales_team' } },
+ edges: [
+ { id: 'e1', source: 'start', target: 'invoice', condition: "record.stage == 'closed_won'" },
+ { id: 'e2', source: 'invoice', target: 'notify' },
+ { id: 'e3', source: 'notify', target: 'end' },
],
});
```
@@ -447,16 +445,22 @@ export const OpportunityWorkflow = Workflow({
### 3. ObjectUI: Define the Kanban View
```typescript
-export const OpportunityKanban = ListView({
+import { defineView } from '@objectstack/spec';
+
+// For a kanban list, set type: 'kanban' and put the board config under
+// `kanban` (groupByField selects the column axis). `columns` is the list
+// of card fields. Drag-and-drop between columns is built in.
+export const OpportunityBoard = defineView({
object: 'opportunity',
- type: 'kanban',
- groupBy: 'stage',
- columns: [
- { field: 'title' },
- { field: 'amount' },
- { field: 'customer' },
- ],
- enableDragDrop: true,
+ list: {
+ type: 'kanban',
+ columns: ['title', 'amount', 'customer'],
+ kanban: {
+ groupByField: 'stage',
+ summarizeField: 'amount',
+ columns: ['title', 'amount'],
+ },
+ },
});
```
@@ -546,7 +550,7 @@ The three protocols are **loosely coupled** but **tightly integrated**:
## Next Steps
-- [ObjectQL: Data Protocol]((/docs/protocol/objectql)) - Full data protocol specification
+- [ObjectQL: Data Protocol](/docs/protocol/objectql) - Full data protocol specification
- [ObjectUI: UI Protocol](/docs/protocol/objectui) - Full view protocol specification
- [ObjectOS: System Protocol](/docs/protocol/objectos) - Full control protocol specification
- [Developer Guide](/docs/getting-started/quick-start) - Build your first ObjectStack application
diff --git a/content/docs/concepts/cloud-artifact-api.mdx b/content/docs/concepts/cloud-artifact-api.mdx
index e878c66e07..9632a50830 100644
--- a/content/docs/concepts/cloud-artifact-api.mdx
+++ b/content/docs/concepts/cloud-artifact-api.mdx
@@ -97,6 +97,11 @@ data-plane request:
5. Configured default environment
6. Single unambiguous environment
+This resolution order is implemented by the cloud host distribution's
+kernel-resolver (`@objectstack/objectos-runtime`). The open-source dispatcher
+(`packages/runtime/src/http-dispatcher.ts`) only provides the seam: when no
+resolver is injected it serves every request from a single default kernel.
+
Control-plane routes under `/api/v1/cloud/environments/...` are management
calls, not data-plane calls.
@@ -108,8 +113,8 @@ calls, not data-plane calls.
|:---|:---|
| `packages/cli/src/commands/publish.ts` | CLI publish command and endpoint construction. |
| `packages/cli/src/commands/rollback.ts` | CLI revision activation command. |
-| `packages/runtime/src/cloud/environment-registry.ts` | Runtime seam for id/hostname environment resolution. |
-| `packages/runtime/src/cloud/runtime-config-plugin.ts` | Console runtime-config for active/default environment state. |
+| `packages/runtime/src/http-dispatcher.ts` | Kernel-resolution seam for per-request environment resolution. The concrete id/hostname registry ships in the host distribution `@objectstack/objectos-runtime` (not part of this open-source repo). |
+| `packages/cloud-connection/src/runtime-config-plugin.ts` | Console runtime-config for active/default environment state. |
| `packages/spec/src/system/environment-artifact.zod.ts` | Normative artifact envelope schema. |
---
diff --git a/content/docs/concepts/cluster-semantics.mdx b/content/docs/concepts/cluster-semantics.mdx
index ae81353b90..9424e39992 100644
--- a/content/docs/concepts/cluster-semantics.mdx
+++ b/content/docs/concepts/cluster-semantics.mdx
@@ -134,8 +134,10 @@ deliverySemantics: 'best-effort' | 'at-least-once' | 'exactly-once'
duplicates are possible during retry. Use for: webhook outbox, audit
shipping, async job enqueue.
- **`exactly-once`** — Reserved keyword; **not implemented in v1**. The
- protocol accepts it so future runtimes can add it without a breaking
- change. Today the runtime rejects it at startup with a clear error.
+ protocol accepts the enum value so future runtimes can add it without a
+ breaking change. Startup rejection of this value is *intended* behaviour
+ but is **not yet implemented** — the bus does not currently inspect or
+ reject it.
### 4.3 Partition key — what ordering does the bus preserve?
@@ -155,9 +157,11 @@ out of emit order.
```ts
eventBus.emit('account.updated', payload, {
- scope: 'cluster',
- deliverySemantics: 'at-least-once',
- partitionKey: payload.id,
+ cluster: {
+ scope: 'cluster',
+ deliverySemantics: 'at-least-once',
+ partitionKey: payload.id,
+ },
})
```
@@ -197,21 +201,44 @@ leaderStrategy: 'leader-elected' | 'partitioned' | 'idempotent-broadcast'
and the work itself is idempotent (writes use UPSERT, side effects are
keyed). Use for: cache invalidation handlers, projection rebuilders.
+These annotations are **nested** under a `cluster` key on the service
+registration (`ServiceMetadata.cluster` / `ServiceFactoryRegistration.cluster`
+= `ServiceClusterAnnotations`), not declared as flat top-level fields:
+
+```ts
+{
+ name: 'cron-scheduler',
+ cluster: {
+ clusterScope: 'cluster',
+ leaderStrategy: 'leader-elected',
+ // clusterId: 'scheduler', // optional — share one leadership lock
+ // across physically different services (e.g. safe rolling upgrades);
+ // defaults to the service name.
+ },
+}
+```
+
> **Default rationale.** `node` is the only safe default. A plugin author
> who forgets to declare scope gets the single-machine behaviour, which is
> always correct; opting up to `cluster` is an explicit decision.
-### 5.1 What the runtime gives you for free
+### 5.1 What the runtime will give you for free
-When a service declares `clusterScope: 'cluster'`, the runtime automatically:
+> **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.
-1. Wraps `onEnable` so the work only starts after lock acquisition (for
+When a service declares `clusterScope: 'cluster'`, the runtime will
+automatically:
+
+1. Wrap `onEnable` so the work only starts after lock acquisition (for
`leader-elected`).
-2. Refreshes the lock TTL via heartbeat while the node is healthy.
-3. Releases the lock on `onDisable` or graceful shutdown.
-4. Emits `cluster:leader-changed` events so dependent services can react.
+2. Refresh the lock TTL via heartbeat while the node is healthy.
+3. Release the lock on `onDisable` or graceful shutdown.
+4. Emit a leadership-change event so dependent services can react.
-A plugin author writing a cron scheduler does **not** write any lock code —
+A plugin author writing a cron scheduler will **not** write any lock code —
they declare scope and strategy, and the runtime does the rest.
## 6. Metadata versioning and cache coherence
@@ -224,14 +251,18 @@ current protocol lacks: a **monotonic version per item** and a
### 6.1 Monotonic version
-Every persisted metadata record gains a `version: bigint` column that
-increments on every write (already present in
-`system/metadata-persistence.zod.ts` as `version: number` for optimistic
-concurrency — this ADR formalises and widens it to `bigint` so caches can
-compare freshness across long-running clusters without overflow).
+A `version` column is already present on every persisted metadata record in
+`system/metadata-persistence.zod.ts` (`version: z.number()`, used today for
+optimistic concurrency). This ADR proposes formalising it as a monotonic
+per-item version and **widening it to `bigint`** so caches can compare
+freshness across long-running clusters without overflow.
+
+> **Status: planned.** The column is still `version: number` in the schema;
+> the widening and the cache-comparison contract below describe the target
+> design, not current runtime behaviour.
-Caches store `{value, version}`. On any incoming change notification the
-cache compares the incoming version with the stored one:
+Once wired, caches store `{value, version}`. On any incoming change
+notification the cache compares the incoming version with the stored one:
- Incoming version `>` stored → invalidate (apply new value or evict)
- Incoming version `≤` stored → ignore (out-of-order notification, already
@@ -240,35 +271,57 @@ cache compares the incoming version with the stored one:
This eliminates a whole class of bugs where a slow-arriving "old"
invalidation evicts a "newer" value the node has already learned about.
-### 6.2 The `metadata:changed` event
+### 6.2 The metadata change event
-Whenever a metadata record is written (create / update / delete / publish),
-the persistence layer **must** emit:
+**Current behaviour.** Cross-node metadata invalidation already works, but
+it does **not** go through `eventBus.emit`. The metadata manager fans out
+over the cluster PubSub channel `metadata.changed` (note: dot, not colon)
+with the payload shape `ClusterMetadataChangedPayload`:
```ts
-eventBus.emit('metadata:changed', {
+// packages/metadata/src/metadata-manager.ts
+ctx.cluster.pubsub.publish('metadata.changed', {
+ originNode, // used for loopback suppression
+ type, // 'object' | 'view' | 'dashboard' | …
+ event, // the local watch event, replayed verbatim on peers
+})
+```
+
+Peers suppress their own messages by `originNode` and replay the watch
+event locally — there is currently **no** `version` / `name` / `tenantId` /
+`operation` field and **no** version comparison.
+
+**Target spec (planned).** The richer, version-stamped payload below is
+defined as `MetadataChangedEventPayloadSchema` in `kernel/cluster.zod.ts`
+but is **not yet wired** into the runtime:
+
+```ts
+// MetadataChangedEventPayloadSchema — target shape, not yet emitted
+{
type: 'object' | 'view' | 'flow' | …,
name: '',
tenantId?: '',
version: ,
operation: 'create' | 'update' | 'delete' | 'publish',
-}, {
- scope: 'cluster',
- deliverySemantics: 'at-least-once',
- partitionKey: `${type}:${name}`,
-})
+}
```
-The partition key guarantees that two rapid updates to the same item are
-applied to every node's cache in the order they occurred. The `at-least-
-once` guarantee means a node briefly partitioned from the bus will catch
-up on reconnect (the transport replays from the last acknowledged offset).
+When wired with `scope: 'cluster'` + `deliverySemantics: 'at-least-once'`
+and a `partitionKey` of `` `${type}:${name}` ``, the partition key would
+guarantee that two rapid updates to the same item are applied to every
+node's cache in order, and the at-least-once guarantee would let a briefly
+partitioned node catch up on reconnect.
### 6.3 Reader contract
-All metadata readers (registry, loader, query engine) must:
+> **Status: planned.** Today readers are invalidated by the `metadata.changed`
+> PubSub fan-out described in §6.2, which replays watch events verbatim
+> without version comparison. The contract below is the target design that
+> goes with the version-stamped payload.
-1. Subscribe to `metadata:changed` on startup.
+All metadata readers (registry, loader, query engine) should:
+
+1. Subscribe to the metadata change channel on startup.
2. Compare incoming `version` with cached `version` before evicting.
3. Treat missing `version` as `0` (legacy compatibility).
@@ -305,18 +358,27 @@ Ask: **if I deploy 5 nodes, should there be 5 of these or 1?**
### 7.3 "I want to read metadata in a long-lived cache"
-You don't write a cache. You ask the kernel for one:
+There is **no** `ctx.cluster.cache(...)` factory. The cluster service
+exposes only the four primitives (`ctx.cluster.{pubsub, lock, kv, counter}`,
+see §3) — caches are *derived* from `KV + PubSub` by `service-cache`, they
+are not a built-in cluster method.
+
+To keep an in-process metadata cache coherent today, subscribe to the
+metadata change channel via PubSub and invalidate on each notification:
```ts
-const cache = ctx.cluster.cache({
- key: m => `obj:${m.name}`,
- version: m => m.version,
- invalidateOn: { type: 'object' },
+ctx.cluster.pubsub.subscribe('metadata.changed', (payload) => {
+ // payload: { originNode?, type, event }
+ if (payload.type === 'object') {
+ myObjectCache.invalidate(payload.event)
+ }
})
```
-The runtime wires `metadata:changed` subscription, version comparison, and
-the underlying KV automatically. No `if (cluster)` branches in your code.
+A higher-level cache factory (with automatic version comparison) is part of
+the Phase 4 / §6 target design but is **not yet implemented**. No
+`if (cluster)` branches are needed either way — the `memory` driver makes
+the subscribe call a no-op-equivalent local fan-out on a single node.
### 7.4 What you must never do
@@ -349,15 +411,17 @@ defineStack({
lockTtlMs: 15000, // 3× heartbeat is the safe ratio
tenantIsolation: 'channel-prefix', // 'channel-prefix' | 'none'
+ // driverOptions: { /* opaque, driver-specific options */ },
},
})
```
Every cluster primitive selects its implementation from `cluster.driver`.
-When the field is absent, the kernel uses the in-memory driver and a
-single-line warning is logged in `production` mode: *"running in single-
-node cluster mode; horizontal scale will be incorrect until cluster.driver
-is configured."*
+When the field is absent, the kernel silently auto-registers the in-memory,
+single-node driver. Emitting a production warning in this case (so operators
+notice that horizontal scale will be incorrect until `cluster.driver` is
+configured) is *intended* but **not yet implemented** — no such warning is
+logged today.
### 8.1 Driver matrix
@@ -455,8 +519,9 @@ a new implementation that calls `registerClusterDriver()`.
## 11. Non-goals (v1)
-- **Exactly-once delivery** — accepted as a keyword in the protocol,
- rejected at runtime startup. Implementing it correctly requires
+- **Exactly-once delivery** — accepted as a keyword in the protocol;
+ startup rejection of the value is intended but not yet implemented.
+ Implementing it correctly requires
distributed transactions or idempotency tokens that are out of scope
for v1.
- **Cross-region replication** — single-region clusters only. Multi-region
diff --git a/content/docs/concepts/core/architecture.mdx b/content/docs/concepts/core/architecture.mdx
index 9302a0dd30..7061d299b6 100644
--- a/content/docs/concepts/core/architecture.mdx
+++ b/content/docs/concepts/core/architecture.mdx
@@ -22,61 +22,69 @@ graph TD
```
### 1. Discovery Phase
-The kernel scans for registered plugins. In a typical app, plugins are explicitly registered via code, but the kernel also supports scanning `package.json` for auto-discovery in certain environments.
+The kernel collects the plugins that have been explicitly registered via `kernel.use(plugin)`. The core kernel has no automatic plugin discovery; every plugin must be registered in code before `bootstrap()` is called.
### 2. Validation Phase
-Each plugin's manifest is validated against the **Plugin Protocol** specific in `@objectstack/spec`.
-- **Name**: Must be unique.
-- **Version**: SemVer compatible.
+Each plugin's structure is validated locally by the plugin loader. There is no spec-driven schema check here — the kernel verifies a small set of required properties:
+- **Name**: Must be present (and unique across registered plugins).
+- **Init hook**: An `init` function is required.
+- **Version**: Must be a valid SemVer string (defaults to `0.0.0` when omitted).
### 3. Resolution Phase
The kernel resolves the dependency graph. If Plugin A depends on Plugin B, the kernel ensures Plugin B is loaded first. Circular dependencies are detected and will throw an error.
-### 4. Init Phase (`onInit`)
-The `onInit` hook is called for all plugins in dependency order. This is where plugins should:
+### 4. Init Phase (`init`)
+The required `init(ctx)` hook is called for all plugins in dependency order. This is where plugins should:
- Register Services involved in Dependency Injection.
- Register Event Listeners.
- **NOT** perform async IO (like database connections) if possible.
-### 5. Start Phase (`onStart`)
-After all plugins have successfully initialized, the `onStart` hook is called in dependency order. This is where the application "comes alive":
+### 5. Start Phase (`start`)
+After all plugins have successfully initialized, the optional `start(ctx)` hook is called in dependency order. This is where the application "comes alive":
- Database connections are established.
- HTTP servers start listening.
- Background jobs are scheduled.
-## IKernel Interface Reference
+Plugins may also implement an optional `destroy()` hook, which the kernel calls in reverse dependency order during `shutdown()` to release resources.
-The `IKernel` is the central orchestrator exposed to the host application.
+## ObjectKernel Public API
+
+The `ObjectKernel` is the central orchestrator exposed to the host application. The methods you call from host code are:
```typescript
-export interface IKernel {
+class ObjectKernel {
/**
- * Start the Kernel
- * Initializes and starts all registered plugins and drivers.
+ * Register a plugin. Must be called before bootstrap().
+ * Returns `this` so calls can be chained.
*/
- start(): Promise;
+ use(plugin: Plugin): Promise;
/**
- * Stop the Kernel
- * Gracefully shuts down all plugins.
+ * Bootstrap the kernel.
+ * Runs the validation -> resolution -> init -> start lifecycle for all
+ * registered plugins, then moves the kernel into the ready state.
*/
- stop(): Promise;
+ bootstrap(): Promise;
/**
- * Get a Service
- * Retrieves a registered service by ID.
+ * Gracefully shut down the kernel, calling each plugin's destroy() hook
+ * in reverse dependency order.
*/
- getService(id: string): T;
+ shutdown(): Promise;
/**
- * Register a Service
- * Manually register a service instance (dynamic registration).
+ * Register a service instance directly.
+ * Returns `this` so calls can be chained.
*/
- registerService(id: string, instance: any): void;
+ registerService(name: string, service: T): this;
/**
- * Check Service Availability
+ * Retrieve a registered service by name.
*/
- hasService(id: string): boolean;
+ getService(name: string): T;
}
```
+
+
+`bootstrap()` / `shutdown()` are the kernel lifecycle methods — there is no `start()` / `stop()` on `ObjectKernel`. (The `start`/`stop` naming applies to the per-plugin `start()` hook, not the kernel itself.)
+
diff --git a/content/docs/concepts/core/events.mdx b/content/docs/concepts/core/events.mdx
index d176f5e8d2..ef2313ad78 100644
--- a/content/docs/concepts/core/events.mdx
+++ b/content/docs/concepts/core/events.mdx
@@ -5,111 +5,114 @@ description: System-wide event bus for loose coupling between plugins
# Events & Hooks
-The Kernel provides a high-performance, typed Event Bus for loose coupling between plugins and the core system.
+ObjectStack has **two distinct hook systems**. They look similar but use different APIs, payloads, ordering, and error semantics — pick the right one for the job:
-## Lifecycle Events
+- **Kernel lifecycle hooks** — `ctx.hook(name, handler)` / `ctx.trigger(name, ...args)` on the `PluginContext`. Used for system bootstrap events and custom plugin-to-plugin events, matched by **exact name**.
+- **Data lifecycle hooks** — object-level hooks (`beforeInsert`, `afterUpdate`, …) registered via object `Hook` metadata or `engine.registerHook()`. They receive a single `HookContext` and run on record mutations.
-Emitted by the Kernel during the bootstrap process:
+## Kernel Lifecycle Hooks
+
+### Lifecycle Events
+
+Triggered by the Kernel during bootstrap and shutdown:
| Event | Description |
| :--- | :--- |
-| `kernel:init` | The kernel has started initialization. |
| `kernel:ready` | All plugins have successfully started. System is live. |
+| `kernel:listening` | Fired after every `kernel:ready` handler has completed (e.g. the HTTP server is accepting connections). |
| `kernel:shutdown` | Shutdown signal received. Plugins should clean up resources. |
-| `kernel:error` | A fatal error occurred during startup or runtime. |
-
-## Data Events
-
-The Data Engine emits events for record mutations. Every mutation triggers a `before` and `after` hook.
-
-| Event | Payload | Cancelable |
-| :--- | :--- | :---: |
-| `data:beforeCreate` | `{ object, record }` | ✅ |
-| `data:afterCreate` | `{ object, record, id }` | ❌ |
-| `data:beforeUpdate` | `{ object, id, changes, previous }` | ✅ |
-| `data:afterUpdate` | `{ object, id, record }` | ❌ |
-| `data:beforeDelete` | `{ object, id }` | ✅ |
-| `data:afterDelete` | `{ object, id }` | ❌ |
-
-
-**Cancelable** events can be aborted by throwing an error in the hook handler. This prevents the operation from proceeding.
-
-
-## Usage
-### Listening to Events
+### Listening to Kernel Events
```typescript
-// Lifecycle hook — runs once when system is ready
ctx.hook('kernel:ready', async () => {
ctx.logger.info('System is ready! Sending startup notification...');
});
-
-// Data hook — runs on every record creation
-ctx.hook('data:beforeCreate', async ({ object, record }) => {
- if (object === 'order' && record.amount > 10000) {
- ctx.logger.warn('Large order detected!');
- // Optionally enrich the record
- record.requires_approval = true;
- }
-});
```
+`ctx.hook(name, handler)` takes exactly two arguments — there is no options/priority parameter. Kernel hooks run in **registration order**, and `ctx.trigger()` awaits each handler sequentially.
+
### Emitting Custom Events
-Plugins can emit their own namespaced events for inter-plugin communication:
+Plugins can trigger their own namespaced events for inter-plugin communication. Use `ctx.trigger()` (it is async and returns a `Promise`); handlers receive the positional arguments you pass to `trigger()`:
```typescript
-// Emit a custom business event
-ctx.emit('order:shipped', { orderId: '123', carrier: 'fedex' });
+// Trigger a custom business event (await it — trigger returns a Promise)
+await ctx.trigger('order:shipped', { orderId: '123', carrier: 'fedex' });
-// Another plugin listens
+// Another plugin listens — the handler receives the same positional args
ctx.hook('order:shipped', async ({ orderId, carrier }) => {
await sendTrackingEmail(orderId, carrier);
});
```
-### Wildcard Listeners
+
+Kernel hooks are matched by **exact name** — there is no wildcard or namespace-glob support. A handler registered under `'order:*'` will never fire for `'order:shipped'`.
+
+
+## Data Lifecycle Hooks
+
+The Data Engine runs hooks around record reads and mutations. Write operations fire `before*` and `after*` events:
-Use wildcards to listen to all events in a namespace:
+| Event | When |
+| :--- | :--- |
+| `beforeInsert` / `afterInsert` | Around record creation. |
+| `beforeUpdate` / `afterUpdate` | Around record update. |
+| `beforeDelete` / `afterDelete` | Around record deletion. |
+
+Read and bulk variants (`beforeFind`/`afterFind`, `beforeCount`, `beforeAggregate`, `beforeUpdateMany`, `beforeDeleteMany`, …) also exist.
+
+### The HookContext
+
+Every data hook is a single-argument handler `(ctx: HookContext) => void | Promise`. The context exposes:
+
+| Field | Description |
+| :--- | :--- |
+| `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.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`, …). |
+| `ctx.api` | Scoped cross-object data access. |
+
+### Registering Data Hooks
```typescript
-// Listen to all data events
-ctx.hook('data:*', async (event) => {
- ctx.logger.debug(`Data event: ${event.type}`, event.payload);
-});
+// Enrich a record before it is created
+engine.registerHook('beforeInsert', async (ctx) => {
+ if (ctx.object === 'order' && ctx.input.doc.amount > 10000) {
+ ctx.logger?.warn?.('Large order detected!');
+ ctx.input.doc.requires_approval = true;
+ }
+}, { object: 'order' });
+
+// React after a record is created
+engine.registerHook('afterInsert', async (ctx) => {
+ await notifyWebhook(ctx.object, ctx.input.doc.id);
+}, { object: 'order' });
```
-### Error Handling in Hooks
+### Error Handling
-Errors in `before` hooks cancel the operation. Errors in `after` hooks are logged but don't roll back:
+What happens when a data hook throws is governed by the hook's **`onError`** policy (`'abort'` | `'log'`, default `'abort'`):
+
+- `onError: 'abort'` (default) — the error rolls back the transaction (when the hook is blocking), cancelling the operation.
+- `onError: 'log'` — the error is logged and execution continues; the operation is **not** cancelled.
```typescript
-ctx.hook('data:beforeCreate', async ({ object, record }) => {
- if (object === 'invoice' && !record.customer_id) {
- throw new Error('Invoice must have a customer'); // Cancels the create
+engine.registerHook('beforeInsert', async (ctx) => {
+ if (ctx.object === 'invoice' && !ctx.input.doc.customer_id) {
+ throw new Error('Invoice must have a customer'); // Aborts the insert (default onError)
}
});
-
-ctx.hook('data:afterCreate', async ({ object, id }) => {
- // This runs after the record is saved
- // Errors here are logged but don't affect the saved record
- await notifyWebhook(object, id);
-});
```
-## Event Ordering
+### Ordering
-Hooks execute in plugin registration order. Use `priority` to control execution:
+Data hooks accept a `priority` option (**default `100`**). They are sorted so that **lower priority values run first**:
```typescript
-ctx.hook('data:beforeCreate', async (event) => {
- // Validation runs first
-}, { priority: 100 });
-
-ctx.hook('data:beforeCreate', async (event) => {
- // Enrichment runs after validation
-}, { priority: 50 });
+engine.registerHook('beforeInsert', validate, { priority: 50 }); // runs first
+engine.registerHook('beforeInsert', enrich, { priority: 100 }); // runs after
```
-
-Higher priority values execute first (default: `0`).
diff --git a/content/docs/concepts/core/index.mdx b/content/docs/concepts/core/index.mdx
index 666d271c29..7588fdb0b6 100644
--- a/content/docs/concepts/core/index.mdx
+++ b/content/docs/concepts/core/index.mdx
@@ -20,7 +20,7 @@ ObjectStack is designed to be:
1. **Plugin Lifecycle Management**: Loading, validating, initializing, and starting plugins in the correct dependency order.
2. **Dependency Injection (DI)**: A central registry for services to communicate without tight coupling.
-3. **Event Bus**: System-wide hook system for intercepting logic (e.g., `data:beforeCreate`, `kernel:ready`).
+3. **Event Bus**: System-wide hook system for intercepting logic (e.g., `data:beforeInsert`, `kernel:ready`).
4. **Configuration Management**: Standardized configuration loading using Zod validation.
## Installation
diff --git a/content/docs/concepts/core/plugins.mdx b/content/docs/concepts/core/plugins.mdx
index b001b0c9f2..1ffe8e70f5 100644
--- a/content/docs/concepts/core/plugins.mdx
+++ b/content/docs/concepts/core/plugins.mdx
@@ -11,39 +11,49 @@ Plugins are the building blocks of ObjectStack. A plugin is a plain JavaScript/T
```typescript
import type { Plugin, PluginContext } from '@objectstack/core';
+import { z } from 'zod';
export class MyPlugin implements Plugin {
// Identity
name = 'com.example.myplugin';
version = '1.0.0';
- description = 'An example plugin';
-
+
// Dependencies (Optional)
- dependencies = {
- '@objectstack/runtime': '^1.0.0'
- };
+ // An array of *plugin names* the kernel must initialize before this one.
+ // This controls init ordering — it is NOT an npm-style version map.
+ dependencies = ['com.objectstack.engine.objectql'];
- // Configuration Schema (Optional Zod Schema)
+ // Configuration Schema (Optional)
+ // Read by the plugin loader to validate config; it lives on the plugin
+ // metadata rather than the base `Plugin` interface.
configSchema = z.object({
apiKey: z.string()
});
/**
- * Initialization Phase
+ * Init Phase (REQUIRED)
* Use this to register services, listeners, or other early setup.
*/
- async onInit(ctx: PluginContext) {
+ async init(ctx: PluginContext) {
// Register a service
- ctx.services.register('myService', new MyService());
+ ctx.registerService('myService', new MyService());
}
/**
- * Start Phase
- * The system is fully initialized. Use this to start servers or background jobs.
+ * Start Phase (Optional)
+ * All plugins are initialized. Use this to start servers or background jobs.
*/
- async onStart(ctx: PluginContext) {
+ async start(ctx: PluginContext) {
console.log('MyPlugin started!');
}
+
+ /**
+ * Destroy Phase (Optional)
+ * Called during kernel shutdown — clean up resources here.
+ */
+ async destroy() {
+ console.log('MyPlugin stopped!');
+ }
}
```
@@ -93,12 +103,12 @@ ObjectStack uses `type` discrimination to optimize runtime behavior, allowing th
* **Use Cases:** RAG Pipelines, Autonomous Agents.
* **Behavior:** Extensions for the AI Gateway.
-## RuntimePlugin Interface Reference
+## Plugin Interface Reference
-The `RuntimePlugin` interface defines the contract that all functionality modules must implement.
+The `Plugin` interface (exported from `@objectstack/core`) defines the contract that all functionality modules must implement.
```typescript
-export interface RuntimePlugin {
+export interface Plugin {
/**
* Plugin Unique Identifier
* Recommended format: com.organization.plugin-name
@@ -106,39 +116,46 @@ export interface RuntimePlugin {
name: string;
/**
- * Plugin Version
- * Must follow Semantic Versioning (SemVer)
+ * Plugin Version (Optional)
*/
version?: string;
/**
- * Description
- * Brief explanation of the plugin's purpose.
+ * Plugin Type (Optional)
+ * One of: standard, ui, driver, server, app, theme, agent.
+ * @default 'standard'
+ */
+ type?: string;
+
+ /**
+ * Dependencies (Optional)
+ * List of other plugin names that this plugin depends on.
+ * The kernel ensures these plugins are initialized before this one.
*/
- description?: string;
+ dependencies?: string[];
/**
- * Initialization Hook
- * Called during Kernel bootstrap. Use this to:
+ * Init Phase (REQUIRED)
+ * Called when the kernel is initializing. Use this to:
* - Register Services
* - Register Event Listeners
* - Extend Metadata
*/
- onInit?: (context: PluginContext) => Promise;
+ init(ctx: PluginContext): Promise | void;
/**
- * Start Hook
+ * Start Phase (Optional)
* Called after all plugins are initialized. Use this to:
* - Start HTTP servers
* - Connect to databases
* - Start background workers
*/
- onStart?: (context: PluginContext) => Promise;
+ start?(ctx: PluginContext): Promise | void;
/**
- * Stop Hook
- * Called during Kernel shutdown. cleanup resources here.
+ * Destroy Phase (Optional)
+ * Called during kernel shutdown. Clean up resources here.
*/
- onStop?: (context: PluginContext) => Promise;
+ destroy?(): Promise | void;
}
```
diff --git a/content/docs/concepts/core/services.mdx b/content/docs/concepts/core/services.mdx
index e1ee1b438f..737e80ee16 100644
--- a/content/docs/concepts/core/services.mdx
+++ b/content/docs/concepts/core/services.mdx
@@ -9,7 +9,7 @@ ObjectStack uses a lightweight **Service Locator pattern** for Dependency Inject
## Concepts
-- **Service Name**: A unique string identifier (e.g., `http-server`, `database`, `auth-provider`).
+- **Service Name**: A unique string identifier (e.g., `http-server`, `data`, `auth`).
- **Service Implementation**: Any JavaScript object, class instance, or function.
- **Service Contract**: A TypeScript interface that defines the expected API surface.
@@ -25,8 +25,9 @@ export const myPlugin: Plugin = {
async init(ctx) {
// Register a service with a concrete implementation
+ const settings = ctx.getService('settings');
ctx.registerService('cache', new RedisCacheProvider({
- url: ctx.config.get('redis.url'),
+ url: settings.get('redis.url'),
}));
},
};
@@ -34,35 +35,45 @@ export const myPlugin: Plugin = {
### Factory Registration (Lazy)
-Use a factory function when the service requires async initialization:
+Use `registerServiceFactory` when the service requires async initialization.
+`registerService` stores a concrete instance as-is, so passing it a function
+would just register the function object. The factory receives the plugin
+context (and an optional scope id) and is wrapped in lifecycle management:
```typescript
-ctx.registerService('database', async () => {
- const pool = await createPool(ctx.config.get('database'));
+import { ServiceLifecycle } from '@objectstack/core';
+
+ctx.registerServiceFactory('data', async (ctx) => {
+ const settings = ctx.getService('settings');
+ const pool = await createPool(settings.get('database'));
return new PostgresDriver(pool);
-});
+}, ServiceLifecycle.SINGLETON);
```
-The factory is called once on first access and the result is cached.
+The lifecycle defaults to `ServiceLifecycle.SINGLETON`, so the factory runs once
+on first access and the instance is cached. Use `ServiceLifecycle.SCOPED` for a
+per-scope (e.g. per-project) instance, or `ServiceLifecycle.TRANSIENT` to create
+a fresh instance on every access.
## Consuming Services
```typescript
-// Synchronous retrieval (if already initialized)
+// Synchronous retrieval (if already registered)
const http = ctx.getService('http-server');
-http.get('/hello', (c) => c.text('Hello'));
+http.get('/hello', (req, res) => res.send('Hello'));
-// Async retrieval (for factory-registered services)
-const db = await ctx.getServiceAsync('database');
-const users = await db.query('user', { filters: [['active', '=', true]] });
+// Async retrieval (for factory- or scope-registered services)
+const db = await ctx.getServiceScoped('data', scopeId);
+const users = await db.find('user', { where: { active: true } });
```
### Optional Services
-Check if a service exists before using it:
+`ctx.getService` throws when a service is not registered. To probe optionally,
+check the registry map with `ctx.getServices()`:
```typescript
-if (ctx.hasService('analytics')) {
+if (ctx.getServices().has('analytics')) {
const analytics = ctx.getService('analytics');
analytics.track('page_view', { url: '/dashboard' });
}
@@ -75,13 +86,14 @@ The core ecosystem defines several standard service contracts:
| Service Name | Interface | Provider Example |
| :--- | :--- | :--- |
| `http-server` | `IHttpServer` | `plugin-hono-server`, `adapter-nextjs` |
-| `database` | `IDatabaseDriver` | `driver-sql` (pg/mysql/sqlite), `driver-mongodb`, `driver-turso` |
+| `data` | `IDataEngine` | `@objectstack/objectql` (drivers implement `IDataDriver`) |
| `auth` | `IAuthService` | `plugin-auth` |
-| `protocol` | `IProtocolEngine` | `@objectstack/objectql` |
-| `api-registry` | `IApiRegistry` | `@objectstack/core` |
-| `cache` | `ICacheProvider` | Redis, Memcached, or in-memory |
-| `logger` | `ILogger` | `@objectstack/core` (built-in) |
-| `event-bus` | `IEventBus` | `@objectstack/core` (built-in) |
+| `api-registry` | `ApiRegistry` | `@objectstack/core` |
+| `cache` | `ICacheService` | Redis, Memcached, or in-memory |
+
+The logger is not a registered service — it is exposed directly as `ctx.logger`
+(the `Logger` contract). Inter-plugin events also do not go through a service:
+use `ctx.hook(name, handler)` and `ctx.trigger(name, ...args)` instead.
## Replacing Core Services
@@ -91,15 +103,17 @@ Swap any core component by providing an alternative plugin:
// Replace the default HTTP server with a custom one
export const customHttpPlugin: Plugin = {
name: 'custom-http',
- provides: ['http-server'], // Declares what this plugin provides
-
+
async init(ctx) {
- ctx.registerService('http-server', new FastifyServer());
+ ctx.replaceService('http-server', new FastifyServer());
},
};
```
-The Kernel ensures only one plugin provides each service name. If multiple plugins declare the same service, the last-registered one wins (or an error is thrown in strict mode).
+`registerService` always throws if the name is already registered — there is no
+strict-mode toggle and no last-registered-wins behavior. To swap an existing
+core service, use `ctx.replaceService(name, implementation)`, which replaces the
+current instance and throws if the service does not yet exist.
## Service Lifecycle
@@ -107,20 +121,24 @@ Services follow the plugin lifecycle:
1. **`init`** — Register services
2. **`start`** — Services are now available to all plugins
-3. **`stop`** — Clean up resources (close connections, flush buffers)
+3. **`destroy`** — Clean up resources (close connections, flush buffers)
```typescript
export const dbPlugin: Plugin = {
name: 'database',
-
+
async init(ctx) {
- const pool = await createPool(ctx.config.get('database'));
- ctx.registerService('database', new PostgresDriver(pool));
+ const settings = ctx.getService('settings');
+ const pool = await createPool(settings.get('database'));
+ ctx.registerService('data', new PostgresDriver(pool));
+ this.db = pool;
},
-
- async stop(ctx) {
- const db = ctx.getService('database');
- await db.close(); // Clean shutdown
+
+ async destroy() {
+ await this.db.close(); // Clean shutdown
},
};
```
+
+The `Plugin` interface defines `init`, `start?`, and `destroy?` — there is no
+`stop` hook, and `destroy()` takes no arguments.
diff --git a/content/docs/concepts/design-principles.mdx b/content/docs/concepts/design-principles.mdx
index dbe4a6b5f7..86963195a3 100644
--- a/content/docs/concepts/design-principles.mdx
+++ b/content/docs/concepts/design-principles.mdx
@@ -77,8 +77,8 @@ We cleanly separate the **Definition** from the **Execution**.
| Layer | Responsibility | Example |
| :--- | :--- | :--- |
-| **Protocol (Mechanism)** | Defines the capabilities. | `allowRead: string` (A slot for a formula) |
-| **App (Policy)** | Defines the business logic. | `allowRead: "$user.role == 'admin'"` |
+| **Protocol (Mechanism)** | Defines the capabilities. | `condition: Expression` (a CEL predicate slot for sharing rules) |
+| **App (Policy)** | Defines the business logic. | `condition: P\`record.department == "Sales"\`` |
| **Engine (Execution)** | Enforces the logic. | Compiles formula to SQL `WHERE` clause. |
**Policies are metadata, not code.** Permission rules, validation predicates, sharing conditions, and flow guards are all expressed as analyzable CEL/Zod metadata — the same engine evaluates them for human users, REST callers, and AI agent tools, and every decision is traceable to a versioned artifact.
@@ -160,4 +160,4 @@ By adhering to these values, we build software that is **resilient to change**,
- [Architecture](/docs/concepts/architecture) - See how these principles shape the system
- [Glossary](/docs/concepts/terminology) - Understand key terms
-- [Core Concepts](/docs/core-concepts) - Learn about metadata-driven development
+- [Core Concepts](/docs/getting-started/core-concepts) - Learn about metadata-driven development
diff --git a/content/docs/concepts/implementation-status.mdx b/content/docs/concepts/implementation-status.mdx
index 59f5c656bf..434bc88448 100644
--- a/content/docs/concepts/implementation-status.mdx
+++ b/content/docs/concepts/implementation-status.mdx
@@ -8,7 +8,7 @@ description: Detailed status of protocol implementations across ObjectStack pack
This document provides a comprehensive overview of which protocols from `@objectstack/spec` have been implemented in the ObjectStack packages.
-**Last Updated**: February 2026
+**Last Updated**: June 2026
This matrix is generated from actual codebase analysis and represents the current implementation status.
@@ -79,17 +79,17 @@ This matrix is generated from actual codebase analysis and represents the curren
|:---------|:----------------------|:------:|:------|
| **Logging** | @objectstack/core | ✅ | Cross-platform logger (browser + server) |
| **API Registry** | @objectstack/core | ✅ | Central endpoint registry |
-| **Metrics** | ❌ | 📋 | Planned |
-| **Tracing** | ❌ | 📋 | Planned |
-| **Audit** | ❌ | 📋 | Planned |
-| **Job** | ❌ | 📋 | Planned |
+| **Metrics** | @objectstack/observability | ✅ | Metrics exporters shipped (`metrics-exporters.ts`) |
+| **Tracing** | @objectstack/observability | ⚠️ | Error/exporter pipeline shipped (`error-exporters.ts`); full distributed tracing in progress |
+| **Audit** | @objectstack/plugin-audit | ✅ | Audit writers + audit objects shipped |
+| **Job** | @objectstack/service-job | ✅ | Job service with cron/db/interval adapters |
| **Cache** | ⚠️ | ⚠️ | HTTP caching implemented, in-memory cache partial |
-| **Translation** | ❌ | 📋 | Planned |
+| **Translation** | @objectstack/service-i18n | ✅ | i18n/translation service with file adapter |
| **Feature Flags** | ❌ | 📋 | Planned |
| **Encryption** | ❌ | 📋 | Planned |
| **Compliance** | ❌ | 📋 | Planned |
| **Masking** | ❌ | 📋 | Planned |
-| **Notification** | ✅ | 🟡 | Framework pipeline shipped; objectui bell cut-over remains |
+| **Notification** | @objectstack/service-messaging, @objectstack/service-feed | 🟡 | Framework pipeline shipped; objectui bell cut-over remains |
| **Change Management** | ❌ | 📋 | Planned |
| **Collaboration** | ❌ | 📋 | Planned |
@@ -151,18 +151,20 @@ This matrix is generated from actual codebase analysis and represents the curren
### HTTP & REST
-| Protocol | @objectstack/runtime | @objectstack/plugin-hono-server | @objectstack/client | Status |
-|:---------|:--------------------:|:-------------------------------:|:-------------------:|:------:|
-| **REST Server** | ✅ | ✅ | ❌ | ✅ Full |
-| **HTTP Server** | ✅ | ✅ | ❌ | ✅ Full |
-| **Endpoint** | ✅ | ✅ | ❌ | ✅ Full |
-| **Router** | ✅ | ✅ | ❌ | ✅ Full |
-| **Discovery** | ✅ | ✅ | ✅ | ✅ Full |
-| **Contract** | ✅ | ❌ | ❌ | ⚠️ Partial |
-| **Protocol** | ✅ | ❌ | ✅ | ✅ Full |
-| **Errors** | ✅ | ✅ | ✅ | ✅ Full |
-| **HTTP Cache** | ✅ | ✅ | ✅ | ✅ Full |
-| **Batch** | ✅ | ✅ | ✅ | ✅ Full |
+| Protocol | @objectstack/rest | @objectstack/runtime | @objectstack/plugin-hono-server | @objectstack/client | Status |
+|:---------|:-----------------:|:--------------------:|:-------------------------------:|:-------------------:|:------:|
+| **REST Server** | ✅ | ✅ (re-export) | ✅ | ❌ | ✅ Full |
+| **HTTP Server** | ❌ | ✅ | ✅ | ❌ | ✅ Full |
+| **Endpoint** | ✅ | ❌ | ✅ | ❌ | ✅ Full |
+| **Router** | ✅ | ✅ | ✅ | ❌ | ✅ Full |
+| **Discovery** | ❌ | ✅ | ✅ | ✅ | ✅ Full |
+| **Contract** | ✅ | ✅ | ❌ | ❌ | ⚠️ Partial |
+| **Protocol** | ❌ | ✅ | ❌ | ✅ | ✅ Full |
+| **Errors** | ✅ | ✅ | ✅ | ✅ | ✅ Full |
+| **HTTP Cache** | ✅ | ✅ | ✅ | ✅ | ✅ Full |
+| **Batch** | ✅ | ✅ | ✅ | ✅ | ✅ Full |
+
+The data (`/data`), metadata (`/meta`), and batch endpoints are implemented in `@objectstack/rest` (`rest-server.ts`); `@objectstack/runtime` re-exports `RestServer` from that package and provides the underlying HTTP server/dispatcher.
**REST Endpoints Implemented:**
@@ -189,8 +191,8 @@ This matrix is generated from actual codebase analysis and represents the curren
| **Analytics** | ✅ | ✅ | ObjectQL aggregation plus `@objectstack/service-analytics` dataset execution; analytics read scope auto-bridges to `security.getReadFilter` for RLS-aware dashboards/reports |
| **OData** | ❌ | 📋 | Protocol defined, not implemented |
| **GraphQL** | ❌ | 📋 | Protocol defined, not implemented |
-| **Realtime** | ❌ | 📋 | Protocol defined, not implemented |
-| **WebSocket** | ❌ | 📋 | Protocol defined, not implemented |
+| **Realtime** | @objectstack/service-realtime | ⚠️ | Realtime service with in-memory adapter shipped; production adapters in progress |
+| **WebSocket** | @objectstack/service-realtime | ⚠️ | Transport provided via the realtime service in-memory adapter |
---
@@ -235,23 +237,23 @@ This matrix is generated from actual codebase analysis and represents the curren
## Automation Layer
-**Plugin-Provided Service** — The kernel does NOT include an automation engine. Flow, workflow, and approval services must be provided by plugins (e.g., `@objectstack/plugin-automation`).
+**Plugin-Provided Service** — The kernel does NOT include an automation engine. Flow, workflow, and approval services are provided by plugins — the flow engine ships in `@objectstack/service-automation`, with approval nodes in `@objectstack/plugin-approvals`.
| Protocol | @objectstack/spec | Kernel | Plugin Required | Status |
|:---------|:-----------------:|:------:|:---------------:|:------:|
-| **Flow** | ✅ | ❌ | ✅ | 📋 Plugin |
-| **Workflow** | ✅ | ❌ | ✅ | 📋 Plugin |
-| **Approval** | ✅ | ❌ | ✅ | 📋 Plugin |
-| **Webhook** | ✅ | ❌ | ✅ | 📋 Plugin |
+| **Flow** | ✅ | ❌ | ✅ | ✅ `@objectstack/service-automation` |
+| **Workflow** | ✅ | ❌ | ✅ | ✅ `@objectstack/service-automation` |
+| **Approval** | ✅ | ❌ | ✅ | ✅ `@objectstack/plugin-approvals` |
+| **Webhook** | ✅ | ❌ | ✅ | ✅ `@objectstack/plugin-webhooks` |
| **ETL** | ✅ | ❌ | ✅ | 📋 Plugin |
| **Sync** | ✅ | ❌ | ✅ | 📋 Plugin |
-| **Trigger Registry** | ✅ | ❌ | ✅ | 📋 Plugin |
+| **Trigger Registry** | ✅ | ❌ | ✅ | ✅ `plugin-trigger-record-change` / `plugin-trigger-schedule` |
**Notes:**
- Hook system is implemented in ObjectQL (beforeFind, afterInsert, etc.) — this is data-layer eventing, not workflow automation
-- Full workflow/automation engine will be provided by plugins
-- Protocols are complete and ready for plugin implementation
+- 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`
+- ETL and Sync protocols are defined but not yet implemented as plugins
- Discovery API reports automation service as `unavailable` until a plugin is registered
---
@@ -292,22 +294,23 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
| Protocol | @objectstack/spec | Implementation | Status |
|:---------|:-----------------:|:-------------:|:------:|
-| **Agent** | ✅ | ❌ | ❌ Spec only |
-| **Model Registry** | ✅ | ❌ | ❌ Spec only |
-| **RAG Pipeline** | ✅ | ❌ | ❌ Spec only |
-| **NLQ** | ✅ | ❌ | ❌ Spec only |
-| **Conversation** | ✅ | ❌ | ❌ Spec only |
-| **Agent Action** | ✅ | ❌ | ❌ Spec only |
+| **Agent** | ✅ | @objectstack/service-ai | ✅ Agent runtime (`agent-runtime.ts`, `agents/`) |
+| **Model Registry** | ✅ | @objectstack/service-ai | ✅ `model-registry.ts` |
+| **RAG Pipeline** | ✅ | @objectstack/service-knowledge | ⚠️ Knowledge service + `knowledge-memory` / `knowledge-ragflow` / `embedder-openai` plugins |
+| **NLQ** | ✅ | @objectstack/service-ai | ⚠️ `tools/query-data`, `schema-retriever.ts` |
+| **Conversation** | ✅ | @objectstack/service-ai | ✅ In-memory + ObjectQL conversation services (`conversation/`) |
+| **Agent Action** | ✅ | @objectstack/service-ai | ⚠️ Action/data/knowledge tools (`tools/`) |
| **Cost** | ✅ | ❌ | ❌ Spec only |
| **Predictive** | ✅ | ❌ | ❌ Spec only |
-| **Orchestration** | ✅ | ❌ | ❌ Spec only |
-| **Feedback Loop** | ✅ | ❌ | ❌ Spec only |
+| **Orchestration** | ✅ | @objectstack/service-ai | ⚠️ Agent tool orchestration |
+| **Feedback Loop** | ✅ | @objectstack/service-ai | ⚠️ Eval harness (`eval/`) |
| **DevOps Agent** | ✅ | ❌ | ❌ Spec only |
**Notes:**
- Complete AI protocol suite defined
-- Ready for AI/ML integration
-- No implementation yet
+- `@objectstack/service-ai` ships agents, model registry, conversation, tools, skills, and an eval harness
+- RAG/embedding is provided by `@objectstack/service-knowledge` plus the `knowledge-memory`, `knowledge-ragflow`, and `embedder-openai` plugins
+- Cost tracking, predictive, and the DevOps agent protocols remain spec-only
---
@@ -315,11 +318,11 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
| Protocol | @objectstack/spec | Implementation | Status |
|:---------|:-----------------:|:-------------:|:------:|
-| **Connector** | ✅ | ❌ | ❌ Spec only |
-| **SaaS Connector** | ✅ | ❌ | ❌ Spec only |
-| **Database Connector** | ✅ | ❌ | ❌ Spec only |
-| **File Storage** | ✅ | ❌ | ❌ Spec only |
-| **Message Queue** | ✅ | ❌ | ❌ Spec only |
+| **Connector** | ✅ | connector-rest, connector-openapi, connector-mcp, connector-slack | ✅ REST/OpenAPI/MCP/Slack connectors shipped |
+| **SaaS Connector** | ✅ | connector-slack | ⚠️ Slack connector shipped; broader SaaS catalogue in progress |
+| **Database Connector** | ✅ | @objectstack/driver-sql, driver-mongodb | ✅ Delivered via the database drivers |
+| **File Storage** | ✅ | @objectstack/service-storage | ✅ File storage service with local + S3 adapters and storage routes |
+| **Message Queue** | ✅ | @objectstack/service-queue | ⚠️ Queue service shipped |
| **GitHub Connector** | ✅ | ❌ | ❌ Spec only |
| **Vercel Connector** | ✅ | ❌ | ❌ Spec only |
@@ -378,7 +381,7 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
- [x] Metadata Management
### Phase 6: Advanced Features 🚧 **IN PROGRESS**
-- [ ] Production Database Drivers (PostgreSQL, MySQL, MongoDB)
+- [x] Production Database Drivers — `@objectstack/driver-sql` (PostgreSQL/MySQL dialects), `@objectstack/driver-mongodb`, and `@objectstack/driver-sqlite-wasm` ship; additional dialect coverage ongoing
- [ ] GraphQL API
- [ ] OData Support
- [ ] Realtime Subscriptions
@@ -430,24 +433,21 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
### Overall Implementation Status
-| Category | Total Protocols | Fully Implemented | Partially Implemented | Not Implemented |
-|:---------|:---------------:|:-----------------:|:---------------------:|:---------------:|
-| **Data** | 16 | 7 | 3 | 6 |
-| **UI** | 10 | 0 | 0 | 10 |
-| **API** | 14 | 11 | 1 | 2 |
-| **System** | 39 | 8 | 1 | 30 |
-| **Auth** (plugin) | 10 | 0 | 0 | 10 |
-| **Automation** (plugin) | 7 | 1 | 0 | 6 |
-| **AI** | 12 | 0 | 0 | 12 |
-| **Integration** | 7 | 0 | 0 | 7 |
-| **QA** | 1 | 1 | 0 | 0 |
-| **TOTAL** | **112** | **28** | **5** | **79** |
+| Category | Total Protocols | Status |
+|:---------|:---------------:|:-------|
+| **Data** | 16 | Core modeling, hooks, and query engine fully implemented; document/mapping/external-lookup still pending |
+| **UI** | 10 | Studio/ObjectUI render most authored surfaces (🟡); full cross-surface renderer parity in progress |
+| **API** | 14 | REST/HTTP/discovery/batch fully implemented; OData/GraphQL pending |
+| **System** | 39 | Logging, audit, job, translation, metrics, notification implemented; several governance services pending |
+| **Auth** (plugin) | 10 | Permission + RLS live (`plugin-security`); identity/sharing/territory still plugin-pending |
+| **Automation** (plugin) | 7 | Flow, workflow, approval, webhook, and triggers implemented; ETL/Sync pending |
+| **AI** | 12 | Agents, model registry, conversation, tools, RAG implemented (`service-ai` + knowledge plugins); cost/predictive/DevOps-agent pending |
+| **Integration** | 7 | REST/OpenAPI/MCP/Slack connectors, file storage, and queue implemented; GitHub/Vercel connectors pending |
+| **QA** | 1 | Fully implemented |
### Implementation Coverage
-- **Fully Implemented**: 25.0% (28/112)
-- **Partially Implemented**: 4.5% (5/112)
-- **Not Implemented**: 70.5% (79/112)
+Implementation spans every layer of the platform. Core infrastructure, data modeling, the REST API, client SDKs, security (Phase-1), automation, AI, and integration all have shipping implementations. Remaining gaps are concentrated in specific protocols (OData/GraphQL, cross-surface UI renderer parity, sharing/territory authorization, ETL/Sync, and a handful of governance and AI-cost services) rather than entire layers. Refer to the per-layer tables above for protocol-level status.
### Core Functionality Status
@@ -462,9 +462,9 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
| **HTTP Caching** | ✅ | Yes |
| **Testing Tools** | ✅ | Yes |
| **UI Rendering** | ❌ | No |
-| **Workflows** | ❌ | No (plugin required) |
-| **Security** | ❌ | No (plugin required) |
-| **AI Features** | ❌ | No |
+| **Workflows** | ✅ | Yes (plugin: `service-automation`) |
+| **Security** | ⚠️ | Partial (Phase-1 RBAC/RLS via `plugin-security`) |
+| **AI Features** | ⚠️ | Partial (`service-ai` + knowledge plugins) |
---
@@ -494,6 +494,8 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
| **Database Driver** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ⚠️ | ❌ |
| **API Mocking** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
+> The REST server, endpoint generation, and data/meta/batch endpoints are implemented in the `@objectstack/rest` package (not shown as a column above); `@objectstack/runtime` re-exports `RestServer` from it.
+
---
## Next Steps
@@ -510,7 +512,7 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
description="How protocols relate to each other"
/>
diff --git a/content/docs/concepts/index.mdx b/content/docs/concepts/index.mdx
index c845706bed..12ccc1c19b 100644
--- a/content/docs/concepts/index.mdx
+++ b/content/docs/concepts/index.mdx
@@ -120,6 +120,10 @@ export const Customer = ObjectSchema.create({
});
```
+
+The object names on this page (`customer`, `opportunity`, `contact`) are illustrative. In a real package, object names carry their domain/package prefix directly in the name (e.g. `crm_customer`, `sys_user`) — there is no separate `namespace` field.
+
+
This definition is **pure metadata**. It doesn't know:
- Who can see this data
- How to render a form
@@ -142,62 +146,70 @@ That's the job of the other layers.
### Example: Permission Rules
-```typescript
-// packages/crm/src/permissions/customer.permission.ts
-import { Permission } from '@objectstack/spec';
+Access control is declared as **permission set** metadata (the `PermissionSet` shape from `@objectstack/spec`). A permission set maps each object to its CRUD/lifecycle flags and, optionally, per-field read/edit flags keyed by `.`:
-export const CustomerPermission = Permission({
- object: 'customer',
- rules: [
- {
- profile: 'sales_rep',
- crud: {
- create: true,
- read: true,
- update: true,
- delete: false, // Only managers can delete
- },
- fieldPermissions: {
- annual_revenue: { read: true, edit: false }, // Read-only
- },
+```typescript
+// packages/crm/src/permissions/sales_rep.permission.ts
+import type { PermissionSet } from '@objectstack/spec';
+
+export const salesRep: PermissionSet = {
+ name: 'sales_rep',
+ label: 'Sales Rep',
+ isProfile: true,
+ objects: {
+ customer: {
+ allowCreate: true,
+ allowRead: true,
+ allowEdit: true,
+ allowDelete: false, // Only managers can delete
},
- {
- profile: 'sales_manager',
- crud: {
- create: true,
- read: true,
- update: true,
- delete: true,
- },
+ },
+ fields: {
+ 'customer.annual_revenue': { readable: true, editable: false }, // Read-only
+ },
+};
+
+// packages/crm/src/permissions/sales_manager.permission.ts
+export const salesManager: PermissionSet = {
+ name: 'sales_manager',
+ label: 'Sales Manager',
+ isProfile: true,
+ objects: {
+ customer: {
+ allowCreate: true,
+ allowRead: true,
+ allowEdit: true,
+ allowDelete: true,
},
- ],
-});
+ },
+};
```
-### Example: Workflow Automation
+### Example: Flow Automation
-```typescript
-// packages/crm/src/workflows/customer.workflow.ts
-import { Workflow } from '@objectstack/spec';
+Business logic is authored as a **Flow** — a graph of nodes and edges — via `defineFlow`. A `record_change` flow runs when a record is written; a `decision` node branches on a bare CEL condition (reference fields directly, e.g. `record.annual_revenue`, and do **not** wrap them in `{…}`):
-export const CustomerWorkflow = Workflow({
- object: 'customer',
- trigger: 'after_create',
- conditions: [
- { field: 'annual_revenue', operator: 'greaterThan', value: 1000000 },
+```typescript
+// packages/crm/src/flows/high_value_customer.flow.ts
+import { defineFlow } from '@objectstack/spec';
+
+export const highValueCustomerFlow = defineFlow({
+ name: 'high_value_customer',
+ label: 'High-Value Customer Alert',
+ type: 'record_change',
+ status: 'active',
+ nodes: [
+ { id: 'start', type: 'start', label: 'On Create' },
+ { id: 'check', type: 'decision', label: 'Is High Value?' },
+ { id: 'assign', type: 'update_record', label: 'Assign Enterprise Team' },
+ { id: 'notify', type: 'notify', label: 'Email Leadership' },
+ { id: 'end', type: 'end', label: 'End' },
],
- actions: [
- {
- type: 'assign_owner',
- params: { owner: 'enterprise_sales_team' },
- },
- {
- type: 'send_email',
- params: {
- template: 'high_value_customer_alert',
- to: 'sales-leadership@company.com',
- },
- },
+ edges: [
+ { id: 'e1', source: 'start', target: 'check' },
+ { id: 'e2', source: 'check', target: 'assign', condition: 'record.annual_revenue > 1000000' },
+ { id: 'e3', source: 'assign', target: 'notify' },
+ { id: 'e4', source: 'notify', target: 'end' },
],
});
```
@@ -219,64 +231,50 @@ ObjectOS **orchestrates** these rules at runtime, independent of the data struct
### Example: List View
-```typescript
-// packages/crm/src/views/customer_list.view.ts
-import { ListView } from '@objectstack/spec';
+Views are authored with `defineView`, which aggregates an object's `list` and `form` views into one document. A list view binds to its data source (`provider: 'object'`), names its columns, and declares any default sort:
-export const CustomerListView = ListView({
- object: 'customer',
- label: 'All Customers',
- type: 'grid',
- columns: [
- { field: 'name', width: 200 },
- { field: 'industry', width: 150 },
- { field: 'annual_revenue', width: 150 },
- { field: 'primary_contact', width: 180 },
- ],
- filters: [
- { field: 'industry', operator: 'equals' },
- { field: 'annual_revenue', operator: 'greaterThan' },
- ],
- defaultSort: { field: 'name', direction: 'asc' },
+```typescript
+// packages/crm/src/views/customer.view.ts
+import { defineView } from '@objectstack/spec';
+
+export const customerViews = defineView({
+ list: {
+ label: 'All Customers',
+ type: 'grid',
+ data: { provider: 'object', object: 'customer' },
+ columns: ['name', 'industry', 'annual_revenue', 'primary_contact'],
+ filter: [
+ { field: 'annual_revenue', operator: 'greaterThan', value: 0 },
+ ],
+ sort: [{ field: 'name', order: 'asc' }],
+ },
});
```
### Example: Form View
-```typescript
-// packages/crm/src/views/customer_form.view.ts
-import { FormView } from '@objectstack/spec';
+The same `defineView` document carries the `form` view. A form is laid out as **sections** of fields (each section can span 1-4 columns):
-export const CustomerFormView = FormView({
- object: 'customer',
- label: 'Customer Details',
- type: 'tabbed',
- tabs: [
- {
- label: 'Overview',
- sections: [
- {
- label: 'Company Information',
- fields: ['name', 'industry', 'annual_revenue'],
- },
- {
- label: 'Contact',
- fields: ['primary_contact'],
- },
- ],
- },
- {
- label: 'Related Records',
- sections: [
- {
- label: 'Opportunities',
- component: 'related_list',
- object: 'opportunity',
- filter: { customer: '$recordId' },
- },
- ],
- },
- ],
+```typescript
+// packages/crm/src/views/customer.view.ts
+import { defineView } from '@objectstack/spec';
+
+export const customerViews = defineView({
+ form: {
+ type: 'simple',
+ data: { provider: 'object', object: 'customer' },
+ sections: [
+ {
+ label: 'Company Information',
+ columns: 2,
+ fields: ['name', 'industry', 'annual_revenue'],
+ },
+ {
+ label: 'Contact',
+ fields: ['primary_contact'],
+ },
+ ],
+ },
});
```
@@ -290,6 +288,10 @@ The UI doesn't "know" the field types. It asks ObjectQL for the schema and rende
Let's trace a **real-world scenario**: A sales rep creates a new high-value customer.
+
+The runtime snippets in Steps 3-6 below are **conceptual pseudo-code** — names like `Auth.getCurrentUser()`, `Permission.check()`, `ObjectQL.getSchema()`, and `Workflow.getTriggersFor()` illustrate *what each layer does at runtime*; they are not importable APIs from `@objectstack/spec`. The schema, view, permission-set, and flow snippets elsewhere on this page are the real authoring surface.
+
+
### Step 1: User Action (ObjectUI)
```
@@ -431,15 +433,25 @@ export const Opportunity = ObjectSchema.create({
### 2. ObjectOS: Define Business Rules
```typescript
-export const OpportunityWorkflow = Workflow({
- object: 'opportunity',
- trigger: 'field_update',
- conditions: [
- { field: 'stage', operator: 'equals', value: 'closed_won' },
+import { defineFlow } from '@objectstack/spec';
+
+export const opportunityWonFlow = defineFlow({
+ name: 'opportunity_won',
+ label: 'Opportunity Won',
+ type: 'record_change',
+ status: 'active',
+ nodes: [
+ { id: 'start', type: 'start', label: 'On Change' },
+ { id: 'check', type: 'decision', label: 'Closed Won?' },
+ { id: 'invoice', type: 'create_record', label: 'Create Invoice' },
+ { id: 'notify', type: 'notify', label: 'Notify Sales Team' },
+ { id: 'end', type: 'end', label: 'End' },
],
- actions: [
- { type: 'create_invoice', params: { object: 'invoice' } },
- { type: 'send_notification', params: { to: 'sales_team' } },
+ edges: [
+ { id: 'e1', source: 'start', target: 'check' },
+ { id: 'e2', source: 'check', target: 'invoice', condition: "record.stage == 'closed_won'" },
+ { id: 'e3', source: 'invoice', target: 'notify' },
+ { id: 'e4', source: 'notify', target: 'end' },
],
});
```
@@ -447,16 +459,19 @@ export const OpportunityWorkflow = Workflow({
### 3. ObjectUI: Define the Kanban View
```typescript
-export const OpportunityKanban = ListView({
- object: 'opportunity',
- type: 'kanban',
- groupBy: 'stage',
- columns: [
- { field: 'title' },
- { field: 'amount' },
- { field: 'customer' },
- ],
- enableDragDrop: true,
+import { defineView } from '@objectstack/spec';
+
+export const opportunityViews = defineView({
+ list: {
+ type: 'kanban',
+ data: { provider: 'object', object: 'opportunity' },
+ columns: ['title', 'amount', 'customer'],
+ kanban: {
+ groupByField: 'stage',
+ summarizeField: 'amount',
+ columns: ['title', 'amount', 'customer'],
+ },
+ },
});
```
@@ -546,7 +561,7 @@ The three protocols are **loosely coupled** but **tightly integrated**:
## Next Steps
-- [ObjectQL: Data Protocol]((/docs/protocol/objectql)) - Full data protocol specification
+- [ObjectQL: Data Protocol](/docs/protocol/objectql) - Full data protocol specification
- [ObjectUI: UI Protocol](/docs/protocol/objectui) - Full view protocol specification
- [ObjectOS: System Protocol](/docs/protocol/objectos) - Full control protocol specification
- [Developer Guide](/docs/getting-started/quick-start) - Build your first ObjectStack application
diff --git a/content/docs/concepts/metadata-driven.mdx b/content/docs/concepts/metadata-driven.mdx
index f5283e38fc..709a00e5a4 100644
--- a/content/docs/concepts/metadata-driven.mdx
+++ b/content/docs/concepts/metadata-driven.mdx
@@ -106,9 +106,10 @@ In metadata-driven development, we embrace three core truths:
The UI doesn't "build" a form; it **projects** the Object schema into visual components.
```typescript
-// The schema IS the form
-const TaskForm =
-// No manual JSX needed
+// Conceptual: the schema IS the form. The runtime renders a view
+// definition (a FormView metadata object) — there is no FormView
+// React component to import; you declare the view, not the JSX.
+const taskForm = { type: 'form', object: 'task' }
```
### 2. The API is a Consequence
@@ -120,11 +121,11 @@ You don't write controllers or routes. ObjectOS *generates* the entire API graph
```bash
# Automatically available after defining the object:
-GET /api/v1/task
-POST /api/v1/task
-GET /api/v1/task/:id
-PATCH /api/v1/task/:id
-DELETE /api/v1/task/:id
+GET /api/v1/data/task
+POST /api/v1/data/task
+GET /api/v1/data/task/:id
+PATCH /api/v1/data/task/:id
+DELETE /api/v1/data/task/:id
```
### 3. The Schema is the Application
@@ -230,7 +231,7 @@ export const Task = ObjectSchema.create({
],
}),
- assignee: Field.lookup('user', {
+ assignee: Field.lookup('sys_user', {
label: 'Assignee',
}),
},
@@ -240,6 +241,8 @@ export const Task = ObjectSchema.create({
// validation, and permission scaffolding are ready.
```
+> **📘 Note**: Built-in platform objects carry system prefixes — the standard user object is `sys_user`, not `user`. Use `Field.lookup('sys_user', ...)` to reference it. Names like `task` or `account` in these examples are illustrative custom objects.
+
### 5. Agent-Ready by Construction
Because objects, fields, relationships, permissions, and workflows are explicit, the runtime can expose a bounded tool surface for AI agents:
@@ -364,9 +367,9 @@ export const TodoTask = ObjectSchema.create({
});
```
-### 3. Select Field Options Must Use Label/Value Objects
+### 3. Prefer Explicit Label/Value Objects for Select Options
-**✅ Correct:**
+**✅ Recommended:**
```typescript
status: Field.select({
label: 'Status',
@@ -378,20 +381,22 @@ status: Field.select({
}),
```
-**❌ Incorrect:**
+**✅ Also valid (auto-normalized):**
```typescript
status: Field.select({
- options: ['open', 'in_progress', 'closed'], // Wrong!
+ options: ['Open', 'In Progress', 'Closed'],
}),
+// Helper converts each string to a lowercase snake_case value:
+// [{ label: 'Open', value: 'open' }, { label: 'In Progress', value: 'in_progress' }, ...]
```
-**Why?** Option values are machine identifiers stored in the database and must be lowercase to avoid case-sensitivity issues in queries.
+**Why?** Option values are machine identifiers stored in the database, so they must be lowercase to avoid case-sensitivity issues in queries. `Field.select()` accepts plain string arrays and auto-normalizes them to lowercase snake_case `{ label, value }` pairs for you. Use explicit `{ label, value }` objects when the display label must differ from the stored value (or to set `default: true`).
### 4. Lookup Fields Must Specify Target Object
**✅ Correct:**
```typescript
-owner: Field.lookup('user', {
+owner: Field.lookup('sys_user', {
label: 'Owner',
required: true,
}),
@@ -488,7 +493,7 @@ export const ExampleObject = ObjectSchema.create({
}),
// Lookup field
- owner: Field.lookup('user', {
+ owner: Field.lookup('sys_user', {
label: 'Owner',
required: true,
}),
@@ -514,7 +519,7 @@ export const ExampleObject = ObjectSchema.create({
## Next Steps
-- [Metadata Lifecycle & HMR](/concepts/metadata-lifecycle) - How writes propagate through the Repository / Change Log / Cache / Registry
-- [The Stack](/docs/core-concepts/the-stack) - How the three protocols work together
-- [Object Model](/docs/core-concepts/object-model) - Deep dive into the universal object model
-- [ObjectQL Protocol](/docs/protocols/objectql) - Learn the data protocol specification
+- [Metadata Lifecycle & HMR](/docs/concepts/metadata-lifecycle) - How writes propagate through the Repository / Change Log / Cache / Registry
+- [Architecture](/docs/concepts/architecture) - How the three protocols work together
+- [Core Concepts](/docs/concepts/core) - Deep dive into the kernel, services, and object model
+- [ObjectQL Protocol](/docs/protocol/objectql) - Learn the data protocol specification
diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx
index 28568f47f1..6177037ec8 100644
--- a/content/docs/concepts/metadata-lifecycle.mdx
+++ b/content/docs/concepts/metadata-lifecycle.mdx
@@ -20,13 +20,13 @@ This page documents the metadata data path introduced by [ADR-0008](/adr/0008-me
│ Studio UI │
│ (useMetadataHmr / HmrStatusBadge) │
└────────────────────┬────────────────────────┘
- │ SSE /api/metadata/hmr
+ │ SSE /api/v1/dev/metadata-events
▼
┌─────────────────────────────────────────────┐
│ MetadataManager (bridge) │
│ forwards Repository events → SSE channel │
└────────────────────┬────────────────────────┘
- │ MetadataEvent{seq, op, ref, ...}
+ │ ChangeEvent{kind, metadataType, name, seq?}
▼
┌─────────────────────────────────────────────┐
│ LayeredRepository │
@@ -49,7 +49,7 @@ Reads walk top-to-bottom: the first non-null layer wins. Writes always route to
| Primitive | Package | Purpose |
| :--- | :--- | :--- |
-| **Repository** | `@objectstack/metadata-core` | CRUD + watch interface over a single metadata source. Implementations: `InMemoryRepository`, `FileSystemRepository`, `SysMetadataRepository`, `LayeredRepository`. |
+| **Repository** | `@objectstack/metadata-core` | CRUD + watch interface over a single metadata source. `InMemoryRepository` and `LayeredRepository` ship from `@objectstack/metadata-core`; `FileSystemRepository` from `@objectstack/metadata-fs`; `SysMetadataRepository` from `@objectstack/objectql`. |
| **Change Log** | `@objectstack/metadata-core` | Append-only log of every mutation, tagged with a monotonic `seq`. Watchers can replay from any `since`. |
| **Cache** | `@objectstack/metadata` | In-memory snapshot of the registry, keyed by `MetaRef`. Invalidated by change-log events. |
| **Registry** | `@objectstack/metadata` | Typed registry the Kernel and plugins query. Built from the cache. |
@@ -69,7 +69,7 @@ When a user edits a view in Studio:
- Verifies `parentVersion` matches the current head (`ConflictError` on mismatch).
- Writes the new row + appends a change-log entry with the next `seq`.
- Emits a `MetadataEvent { op: 'create' | 'update' | 'delete', ref, seq, source }`.
-5. The `MetadataManager` bridge forwards the event over the SSE channel `/api/metadata/hmr` with the `seq` attached.
+5. The `MetadataManager` bridge forwards the event over the SSE channel `/api/v1/dev/metadata-events`. The wire payload is **not** the internal `MetadataEvent` — it is a `ChangeEvent { kind: 'metadata-change', type, metadataType, name, path?, timestamp, seq? }` (emitted as `event: metadata-change`). `seq` is the canonical repository sequence and is **absent** for FS-watcher (chokidar) dev events.
6. The Studio's `useMetadataHmr` hook receives the event, updates `lastSeq`, and triggers a refetch of the affected view.
The `seq` is the single source of truth for ordering. The Studio status badge (`HmrStatusBadge`) shows `Repo seq: #N` in its tooltip.
@@ -83,12 +83,12 @@ In shared-database multi-tenancy, **most metadata types must not be per-org cust
| Type | `allowOrgOverride` | Rationale |
| :--- | :---: | :--- |
| `view`, `dashboard`, `report`, `email_template` | ✅ | Pure rendering. Per-org customization is safe. |
+| `flow`, `agent` | ✅ | Per-org overlays are allowed for automation and agent definitions. |
+| `permission`, `role`, `profile` | ✅ | Per-org overlays are allowed; tenant-level controls layer on top. |
| `object`, `field` | ❌ | Defines the table schema. Overriding would break existing data. |
-| `flow`, `workflow`, `agent` | ❌ | Stateful execution; per-org overrides need a separate execution-scoping mechanism. |
-| `permission`, `role`, `profile` | ❌ | Security; tenant-level controls already exist via role assignments. |
| `datasource` | ❌ | Connection strings; multi-tenant isolation is enforced at a higher layer. |
-The runtime gate is implemented in `OVERLAY_ALLOWED_TYPES` (derived from the registry) and enforced by `SysMetadataRepository.put()`. Denied types return `403 not_overridable`.
+There is no `workflow` metadata type (per [ADR-0020](/adr/0020-state-machine-converge-and-enforce), record state machines are a `state_machine` validation). The runtime gate is implemented in `OVERLAY_ALLOWED_TYPES` (derived from the registry) and enforced by `SysMetadataRepository.put()`. Denied types return `403 not_overridable`.
See [ADR-0005](/adr/0005-metadata-customization-overlay) for the full design and amendments.
@@ -105,7 +105,7 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s
## HMR end-to-end (latency & ordering)
- **Latency.** A typical write-to-render round-trip is **< 100ms** on localhost (REST `PUT` → DB write → SSE flush → Studio refetch → React re-render).
-- **Ordering.** The `seq` is monotonic per repository. If the Studio tab disconnects and reconnects, it can resume from `lastSeq` to replay missed events. (Replay is implemented by `InMemoryRepository`/`SysMetadataRepository`; the SSE bridge passes `since` as a query param.)
+- **Ordering.** The `seq` is monotonic per repository. The repository-level `watch()` API supports replay from a `since` cursor, but the dev HMR SSE endpoint does **not** replay on reconnect — it registers a fresh listener, emits a `ready` event, and then streams only live events. A tab that disconnects and reconnects will miss any events that occurred while it was offline; it should refetch the affected metadata on reconnect.
- **Multi-tab.** Each tab has its own `seq` counter. Out-of-order delivery between tabs is impossible because they share the server change log.
---
@@ -126,13 +126,13 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s
| :--- | :--- |
| `InMemoryRepository`, `FileSystemRepository`, `LayeredRepository` | ✅ Shipped (`@objectstack/metadata-core`) |
| Change log + `seq` (per-org, monotonic) | ✅ Shipped |
-| SSE bridge (`/api/metadata/hmr` with `seq`) | ✅ Shipped |
+| SSE bridge (`/api/v1/dev/metadata-events`, `event: metadata-change`) | ✅ Shipped |
| Studio `useMetadataHmr` + `HmrStatusBadge` | ✅ Shipped |
| Console dev-mode HMR reloader (`MetadataHmrReloader`) | ✅ Shipped |
| `SysMetadataRepository` (overlay over `sys_metadata`) | ✅ Shipped (`@objectstack/objectql`) |
| `LayeredRepository(SysMeta + artifact)` composition | ✅ Shipped |
| `protocol.ts:saveMetaItem` routed through `SysMetadataRepository.put` | ✅ Shipped (PR-10d.6, flag removed) |
-| `sys_metadata_history` table (durable, org-keyed change log) | ⏳ M1 |
+| `sys_metadata_history` table (durable, org-keyed change log) | ✅ Shipped (`@objectstack/objectql`) |
| Cache + Registry refactor against `MetadataRepository` | ⏳ Post-M0 |
> **Cross-replica overlay sync is out of scope.** Single-instance deployments
@@ -150,8 +150,8 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s
| Where the metadata came from | Lands in `sys_metadata`? | Lands in history? |
|:---|:---|:---|
| `defineView(...)` / `defineFlow(...)` / any source file → compiled into `dist/objectstack.json` | ❌ Never. Loaded into the in-memory registry on boot; refreshed via HMR in dev. | ❌ The artifact's own version history *is* Git. The metadata layer does not duplicate it. |
-| Editing a `.json` under `.objectstack/metadata//.json` (FS overlay) | ❌ FS layer is independent of DB. | ✅ Appended to `.objectstack/.log/main.jsonl` by `FileSystemRepository`. |
-| Studio inline edit, or `PUT /api/v1/metadata/...` (REST) on an `allowOrgOverride: true` type | ✅ Written by `SysMetadataRepository.put()` as an **overlay row** scoped to `organization_id`. | ⚠️ M0: in-process watch broadcast only — no durable history table yet. M1: appended to `sys_metadata_history` with a Postgres `SERIAL seq` (single-instance scope; no cross-replica push). |
+| Editing a `.json` under `//.json` (FS overlay, e.g. `/view/case_grid.json`) | ❌ FS layer is independent of DB. | ✅ Appended to the change log at `/.objectstack/.log/main.jsonl` by `FileSystemRepository`. |
+| Studio inline edit, or `PUT /api/v1/metadata/...` (REST) on an `allowOrgOverride: true` type | ✅ Written by `SysMetadataRepository.put()` as an **overlay row** scoped to `organization_id`. | ✅ Appended to `sys_metadata_history` (per-org `event_seq`) in the **same transaction** as the `sys_metadata` write (single-instance scope; no cross-replica push). |
| Deploying a new build (new `dist/objectstack.json`) | ❌ The artifact is loaded into memory, not synced into `sys_metadata`. | ❌ Use Git tags / your deployment platform's release log; that's where artifact "version history" lives. |
### Why artifact never enters the database
diff --git a/content/docs/concepts/north-star.mdx b/content/docs/concepts/north-star.mdx
index 0c63d06c59..599ddb18a2 100644
--- a/content/docs/concepts/north-star.mdx
+++ b/content/docs/concepts/north-star.mdx
@@ -59,8 +59,10 @@ development live outside this repo.
## Environment Artifact
-`os compile` produces a deployable environment artifact. The envelope is defined
-by `EnvironmentArtifactSchema` in
+`os compile` produces the local runtime bundle (`dist/objectstack.json`,
+validated against `ObjectStackDefinitionSchema`). When published to Cloud it is
+wrapped in the immutable environment artifact envelope defined by
+`EnvironmentArtifactSchema` in
`packages/spec/src/system/environment-artifact.zod.ts`.
The artifact contains:
diff --git a/content/docs/concepts/packages.mdx b/content/docs/concepts/packages.mdx
index 8fb1cd9273..c900cd0ff9 100644
--- a/content/docs/concepts/packages.mdx
+++ b/content/docs/concepts/packages.mdx
@@ -5,7 +5,7 @@ description: Complete reference of all ObjectStack packages in the monorepo
# Package Reference
-ObjectStack is distributed as a monorepo containing **70 package manifests** organized into core runtime, client SDKs, framework adapters, drivers, plugins, and platform services.
+ObjectStack is distributed as a monorepo containing **~75 package manifests** organized into core runtime, client SDKs, framework adapters, drivers, plugins, connectors, triggers, and platform services.
> **Note for AI Agents**: Each package's `README.md` contains a specific architectural role and usage rules section.
@@ -13,14 +13,17 @@ ObjectStack is distributed as a monorepo containing **70 package manifests** org
| Category | Count | Description |
| :--- | :---: | :--- |
-| Core runtime | 9 | `spec`, `core`, `runtime`, `types`, `metadata`, `objectql`, `rest`, `formula`, `platform-objects` |
+| Core runtime | 11 | `spec`, `core`, `runtime`, `types`, `metadata`, `metadata-core`, `metadata-fs`, `objectql`, `rest`, `formula`, `platform-objects` |
| Client / DX | 5 | `client`, `client-react`, `cli`, `create-objectstack`, `vscode-objectstack` |
| Framework adapters | 7 | Express, Fastify, Hono, NestJS, Next.js, Nuxt, SvelteKit |
| Drivers | 4 | `driver-memory`, `driver-sql`, `driver-sqlite-wasm`, `driver-mongodb` |
-| Plugins | 22 | Auth, security, audit, approvals, sharing, email, webhooks, reports, Hono/MCP servers, MSW/dev, triggers, knowledge, and embedders |
+| Plugins | 18 | Auth, security, audit, org-scoping, approvals, sharing, email, webhooks, reports, Hono/MCP servers, MSW/dev, record-change & schedule triggers, knowledge, and embedders |
+| Connectors | 4 | `connector-rest`, `connector-mcp`, `connector-openapi`, `connector-slack` |
+| Triggers | 3 | `trigger-api`, `trigger-record-change`, `trigger-schedule` |
| Platform services | 17 | AI, Analytics, Automation, Cache, Cluster, Datasource, Feed, I18n, Job, Knowledge, Messaging, Package, Queue, Realtime, Settings, Storage |
+| Other | ~6 | `mcp`, `console`, `cloud-connection`, `observability`, app templates, and tooling |
-**Total: 70 package manifests**
+**Total: ~75 package manifests.** Counts drift as packages are added — run `find packages -name package.json -not -path '*/node_modules/*'` for the exact current set.
---
@@ -32,20 +35,25 @@ ObjectStack is distributed as a monorepo containing **70 package manifests** org
**Purpose:** The foundational package containing all protocol definitions and schemas. This is the "DNA" of ObjectStack.
-**Key Exports:**
-- `Data` - Data protocol schemas (Field, Object, Query, etc.)
-- `UI` - UI protocol schemas (View, App, Dashboard, etc.)
-- `System` - System protocol schemas (Manifest, Plugin, Events, etc.)
-- `AI` - AI protocol schemas (Agent, RAG Pipeline, etc.)
-- `API` - API protocol schemas (Contract, Endpoint, etc.)
-- `Automation` - Automation protocol schemas (Flow, Workflow, etc.)
-- `Auth` - Authentication protocol schemas
-- `Permission` - Permission protocol schemas
-- `Hub` - Hub protocol schemas
-- `Integration` - Integration protocol schemas
-- `Shared` - Shared utilities and identifiers
-
-**Protocol Count:** 175 Zod schemas
+**Protocol Domains (subpath imports):**
+
+The package does **not** export schemas from the root; import the domain you need via its subpath, e.g. `import * as Data from '@objectstack/spec/data'`.
+
+- `@objectstack/spec/data` - Data protocol schemas (Field, Object, Query, etc.)
+- `@objectstack/spec/ui` - UI protocol schemas (View, App, Dashboard, etc.)
+- `@objectstack/spec/system` - System protocol schemas (Manifest, Plugin, Events, Auth config, etc.)
+- `@objectstack/spec/identity` - Identity & authentication schemas
+- `@objectstack/spec/security` - Permission and security schemas
+- `@objectstack/spec/ai` - AI protocol schemas (Agent, RAG Pipeline, etc.)
+- `@objectstack/spec/api` - API protocol schemas (Contract, Endpoint, etc.)
+- `@objectstack/spec/automation` - Automation protocol schemas (Flow, Workflow, etc.)
+- `@objectstack/spec/integration` - Integration protocol schemas
+- `@objectstack/spec/contracts` - Service contract interfaces
+- `@objectstack/spec/kernel` - Kernel protocol schemas
+- `@objectstack/spec/shared` - Shared utilities and identifiers
+- Additional subpaths: `studio`, `cloud`, `qa`
+
+**Protocol Count:** Hundreds of Zod schemas across these domains — the set grows over time, so refer to the [Protocol Reference](/docs/references) for the current catalog.
**Learn more:** [Protocol Reference](/docs/references)
@@ -217,7 +225,12 @@ ObjectStack is distributed as a monorepo containing **70 package manifests** org
| Development | `init`, `dev`, `serve` |
| Build & Validate | `compile`, `validate`, `info` |
| Scaffolding | `generate` (alias: `g`), `create` |
-| Quality | `test`, `doctor` |
+| Quality | `test`, `doctor`, `lint` |
+| Publishing & Registry | `build`, `publish`, `register`, `rollback`, `login`, `logout`, `whoami` |
+| Inspection | `diff`, `explain` |
+| Command groups | `cloud`, `data`, `datasource`, `environments`, `i18n`, `meta`, `package`, `plugin` |
+
+> This is a representative list; run `os --help` for the authoritative set of commands and subcommands.
**Key Features:**
- **Project Initialization**: `os init` creates projects from templates (app, plugin, empty)
@@ -254,7 +267,9 @@ ObjectStack is distributed as a monorepo containing **70 package manifests** org
- **MetadataManager**: Orchestrates loading, saving, watching, caching metadata
- **MetadataPlugin**: Kernel plugin adapter for metadata system
- **Loaders**:
- - `FilesystemLoader` - Loads metadata from disk (rootDir)
+ - `MemoryLoader` - In-memory metadata source
+ - `RemoteLoader` - Loads metadata from a remote endpoint
+ - `DatabaseLoader` - Loads metadata persisted in the database
- **Serializers**:
- `JSONSerializer` - `.json` files
- `YAMLSerializer` - `.yaml` files
@@ -273,6 +288,30 @@ ObjectStack is distributed as a monorepo containing **70 package manifests** org
---
+### @objectstack/metadata-core
+
+**Description:** Metadata Repository contracts (ADR-0008) — types, canonicalization, errors, and the `MetadataRepository` interface.
+
+**Purpose:** The shared contract layer the metadata system and its storage backends implement.
+
+**Use Cases:** Building custom metadata repositories, integrating with the metadata system.
+
+**Implementation Status:** ✅ **IMPLEMENTED**
+
+---
+
+### @objectstack/metadata-fs
+
+**Description:** `FileSystemRepository` — a Node-only `MetadataRepository` implementation backed by JSON files and a JSONL change log (ADR-0008).
+
+**Purpose:** Persists metadata on the filesystem with an append-only change history.
+
+**Use Cases:** Local development, file-based metadata storage, change auditing.
+
+**Implementation Status:** ✅ **IMPLEMENTED**
+
+---
+
### @objectstack/types
**Description:** Shared Runtime Type Definitions
@@ -412,18 +451,17 @@ Framework adapters that bridge ObjectStack's unified `HttpDispatcher` to specifi
**Purpose:** A reference in-memory database driver used for testing and development.
**Key Features:**
-- **CRUD Operations**: create, read, update, delete, find
-- **Pagination Support**: Offset-based pagination
+- **CRUD & Bulk Operations**: create, read, update, delete, find, plus bulk insert/update/delete
+- **Mingo Query Engine**: MongoDB-compatible filtering, projection, and distinct values
+- **Aggregation Pipeline**: `$match`, `$group`, `$sort`, `$project`, `$unwind`, and an `IAnalyticsService` implementation (`memory-analytics`)
+- **Sorting & Pagination**: `orderBy` sorting with offset-based pagination
+- **Snapshot Transactions**: `beginTransaction` / `commit` / `rollback` (capability flag `transactions: true`)
- **Health Checks**: Built-in health monitoring
- **Array & JSON Fields**: Support for complex field types
-- **Simple Implementation**: No external dependencies
**Limitations:**
-- ⚠️ No transaction support
-- ⚠️ No advanced filtering (basic only)
-- ⚠️ No aggregations
-- ⚠️ No sorting
-- ⚠️ No window functions or subqueries
+- ⚠️ Non-persistent — all data is held in memory and lost on restart
+- ⚠️ No savepoints within a transaction (`savepoints: false`)
**Use Cases:**
- Testing ObjectStack applications
@@ -433,7 +471,7 @@ Framework adapters that bridge ObjectStack's unified `HttpDispatcher` to specifi
**Status:** ✅ Reference implementation for driver developers
-**Implementation Status:** ⚠️ **PARTIALLY IMPLEMENTED** - Basic CRUD only, suitable for testing
+**Implementation Status:** ✅ **FULLY IMPLEMENTED** (in-memory) - Mingo-backed filtering, aggregation, sorting, and snapshot transactions; non-persistent
---
@@ -494,12 +532,14 @@ Framework adapters that bridge ObjectStack's unified `HttpDispatcher` to specifi
**Purpose:** Provides authentication and identity management services for ObjectStack applications with better-auth integration.
**Key Features:**
+- **better-auth Integration**: Wires better-auth's server-side authentication pipeline (sign-up/sign-in, sessions, tokens)
- **Plugin Lifecycle**: Full init/start/destroy lifecycle implementation
- **Service Registration**: Registers `auth` service in ObjectKernel
-- **HTTP Route Scaffolding**: `/api/v1/auth/*` endpoints via IHttpServer
+- **HTTP Routes**: `/api/v1/auth/*` endpoints via IHttpServer, plus a `/set-initial-password` route
- **Configuration Support**: Uses `AuthConfig` schema from `@objectstack/spec/system`
- **OAuth Provider Support**: Configuration for Google, GitHub, Microsoft, etc.
-- **ObjectQL Database**: Uses ObjectQL for data persistence (no ORM required)
+- **ObjectQL Adapter**: Persists users/sessions through ObjectQL (no separate ORM required)
+- **Password Hashing**: Cryptographic password hashing via `@noble/hashes`
- **Advanced Features**: Organization/team support, 2FA, passkeys, magic links
**API Routes:**
@@ -514,9 +554,7 @@ Framework adapters that bridge ObjectStack's unified `HttpDispatcher` to specifi
- OAuth social login integration
- Secure session management
-**Status:** 🟡 **IN DEVELOPMENT** - Structure complete, authentication logic planned
-
-**Implementation Status:** ⚠️ **PARTIALLY IMPLEMENTED** - Plugin structure and routes scaffolded, authentication logic to be added with better-auth integration
+**Implementation Status:** ✅ **FULLY IMPLEMENTED** - Shipped better-auth integration with an ObjectQL persistence adapter, password hashing, and session/token management
**Learn more:** [Auth Config Reference](/docs/references/system/auth-config)
@@ -550,6 +588,30 @@ Framework adapters that bridge ObjectStack's unified `HttpDispatcher` to specifi
---
+### @objectstack/plugin-audit
+
+**Description:** Audit Plugin for ObjectStack — system audit log object and audit trail.
+
+**Key Features:** Records create/update/delete activity into a system audit-log object for compliance and forensics.
+
+**Use Cases:** Compliance audit trails, change tracking, security investigations.
+
+**Implementation Status:** ✅ **IMPLEMENTED**
+
+---
+
+### @objectstack/plugin-org-scoping
+
+**Description:** Organization-Scoping Plugin for ObjectStack — row-level organization isolation.
+
+**Key Features:** Per-org row-level isolation, per-org seed replay, default-org bootstrap.
+
+**Use Cases:** Multi-tenant data isolation, organization-scoped applications.
+
+**Implementation Status:** ✅ **IMPLEMENTED**
+
+---
+
### @objectstack/plugin-dev
**Description:** Development Mode Plugin for ObjectStack
@@ -691,8 +753,98 @@ Framework adapters that bridge ObjectStack's unified `HttpDispatcher` to specifi
---
+## Connectors
+
+Connectors register concrete `request`/action handlers on the automation engine's connector registry, letting flows call external systems (ADR-0018 Addendum, ADR-0022/0023/0024).
+
+### @objectstack/connector-rest
+
+**Description:** Generic REST connector — the reference concrete connector that registers a `request` action on the connector registry.
+
+**Use Cases:** Calling arbitrary REST APIs from flows.
+
+**Implementation Status:** ✅ **IMPLEMENTED**
+
+---
+
+### @objectstack/connector-openapi
+
+**Description:** OpenAPI 3.x connector generator — turns a declarative OpenAPI document into connector actions, with a self-contained static-auth HTTP transport.
+
+**Use Cases:** Integrating any OpenAPI-described service as flow actions.
+
+**Implementation Status:** ✅ **IMPLEMENTED**
+
+---
+
+### @objectstack/connector-mcp
+
+**Description:** Model Context Protocol (MCP) connector — turns any MCP server's tools into a connector's actions on the automation engine.
+
+**Use Cases:** Exposing MCP-server tools to flows and agents.
+
+**Implementation Status:** ✅ **IMPLEMENTED**
+
+---
+
+### @objectstack/connector-slack
+
+**Description:** Slack Web API connector — registers `chat.postMessage` / `chat.update` / `call` actions on the connector registry.
+
+**Use Cases:** Posting Slack messages and triggering Slack calls from flows.
+
+**Implementation Status:** ✅ **IMPLEMENTED**
+
+---
+
+## Triggers
+
+Trigger packages auto-launch flows in response to events (ADR-0018, ADR-0041).
+
+### @objectstack/trigger-record-change
+
+**Description:** Record-change flow trigger — auto-launches flows on object insert/update/delete via ObjectQL lifecycle hooks.
+
+**Use Cases:** Event-driven automation reacting to data changes.
+
+**Implementation Status:** ✅ **IMPLEMENTED**
+
+---
+
+### @objectstack/trigger-schedule
+
+**Description:** Schedule flow trigger — auto-launches flows on a cron/interval/once schedule via the `IJobService`.
+
+**Use Cases:** Scheduled and recurring automation.
+
+**Implementation Status:** ✅ **IMPLEMENTED**
+
+---
+
+### @objectstack/trigger-api
+
+**Description:** Inbound HTTP/webhook flow trigger — per-flow HMAC-verified endpoints with queue-backed ingestion.
+
+**Use Cases:** Launching flows from external webhooks and inbound HTTP calls.
+
+**Implementation Status:** ✅ **IMPLEMENTED**
+
+---
+
## Tools
+### @objectstack/mcp
+
+**Description:** ObjectStack as an MCP server — exposes your app's objects (and AI tools) over the Model Context Protocol (stdio + Streamable HTTP).
+
+**Purpose:** Lets MCP clients (and AI agents) discover and operate on ObjectStack data and tools.
+
+**Use Cases:** Exposing an ObjectStack app to AI assistants and MCP-aware tooling.
+
+**Implementation Status:** ✅ **IMPLEMENTED**
+
+---
+
### @objectstack/rest
**Description:** ObjectStack REST API Server — automatic REST endpoint generation from protocol
@@ -829,7 +981,7 @@ pnpm add -g @objectstack/cli
All packages in the monorepo are versioned together and released simultaneously to ensure compatibility.
-**Current Version:** Check [CHANGELOG.md](https://github.com/objectstack-ai/spec/blob/main/CHANGELOG.md)
+**Current Version:** Check [CHANGELOG.md](https://github.com/objectstack-ai/framework/blob/main/CHANGELOG.md)
**Compatibility Matrix:**
@@ -845,15 +997,15 @@ All packages in the monorepo are versioned together and released simultaneously
diff --git a/content/docs/concepts/setup-app.mdx b/content/docs/concepts/setup-app.mdx
index 76af2533f4..b1d33d1609 100644
--- a/content/docs/concepts/setup-app.mdx
+++ b/content/docs/concepts/setup-app.mdx
@@ -5,83 +5,105 @@ description: The platform's built-in administration UI — what it is, what it s
# Setup App
-The **Setup App** (`/_studio/apps/setup`) is the built-in administration
-console for every ObjectStack project. It lists every `sys_*` platform
-object, plus two pre-baked dashboards, behind a fixed left-hand
-navigation tree.
-
-It used to be assembled at runtime by a dedicated `SetupPlugin` (which
-walked every loaded plugin asking "do you contribute Setup entries?").
-That plugin is **gone** as of the static-Setup-App refactor — the entire
-app is now a fixed metadata artifact exported from
-`@objectstack/platform-objects` and registered exactly like any other
-ObjectStack app.
+The **Setup App** (`/apps/setup`) is the built-in administration console
+for every ObjectStack project. It surfaces the platform `sys_*` objects,
+the System Overview dashboard, and a set of settings pages behind a
+left-hand navigation tree.
+
+Under ADR-0029 D7 the app is **not** a fixed navigation tree. `SETUP_APP`
+ships as a thin **shell** of stable, empty navigation group anchors
+("slots"); the actual menu entries are contributed at runtime by the
+packages that own the underlying objects, via `navigationContributions`.
+The runtime merges every contribution into the app's `navigation` tree by
+group id + priority on read, so the rendered menu reflects exactly which
+capability plugins are loaded — a disabled capability contributes nothing
+and its slot stays empty.
+
+The app itself ships from the dedicated `@objectstack/setup` package
+(package id `com.objectstack.setup`), which registers it at runtime. Per
+ADR-0048 (one app per package), `/apps/setup` — resolvable as
+`/apps/` — maps to exactly this app.
## Where it lives
| File | Role |
|:---|:---|
-| `packages/platform-objects/src/apps/setup.app.ts` | The `App` definition — navigation tree, branding, required permissions |
+| `packages/platform-objects/src/apps/setup.app.ts` | The `App` definition — the navigation **shell** (group anchors), branding, required permissions |
+| `packages/platform-objects/src/apps/setup-nav.contributions.ts` | `SETUP_NAV_CONTRIBUTIONS` — the nav entries owned by `@objectstack/platform-objects`, merged into the shell at runtime |
| `packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts` | "Overview → System Overview" dashboard |
-| `packages/platform-objects/src/apps/dashboards/security_overview.dashboard.ts` | "Overview → Security Overview" dashboard |
-| `packages/platform-objects/src/apps/views/*.view.ts` | List views referenced by the navigation entries |
-| `@objectstack/plugin-auth` | Performs the runtime registration via `manifest.register({...})` (auth is always loaded alongside security + audit) |
+| `packages/apps/setup/src/index.ts` | `@objectstack/setup` package — `SetupAppPlugin.start()` performs the runtime registration via `manifest.register({ apps: [SETUP_APP], navigationContributions: SETUP_NAV_CONTRIBUTIONS })` |
-> Why does `plugin-auth` register Setup? Because the Setup App's
-> `requiredPermissions: ['setup.access']` permission is contributed by
-> `plugin-auth`. Co-locating registration with the permission source
-> avoids load-order surprises.
+> The `requiredPermissions: ['setup.access']` permission declared on the
+> Setup App is **contributed by `plugin-security`** (in its default
+> permission sets), not by the package that registers the app. Other
+> capability plugins (e.g. `plugin-audit`, `plugin-webhooks`,
+> `plugin-approvals`) contribute their own Setup nav entries into the
+> shared group anchors.
## Navigation
-Menu shape: a flat `navigation[]` array with `type: 'group'` category
-nodes and `type: 'object' | 'dashboard'` leaves — the same convention used
-by the CRM example app.
+`setup.app.ts` defines the stable group anchors (the shell); the entries
+under each group are merged in from the contributing packages. The group
+anchors are:
-| Group | Entries |
+| Group (anchor id) | Filled by |
|:---|:---|
-| **Overview** | System Overview · Security Overview |
-| **Administration** | Users · Organizations · Teams · API Keys · Roles · Permission Sets · **OAuth Apps** · **Signing Keys** |
-| **Platform** | Objects · Views · Flows · AI Agents · AI Tools · Apps · Packages · Installations · All Metadata |
-| **System** | **Sessions** · Audit Logs · **Activity** · **Comments** |
-
-Bold entries are recently added:
-
-- **Sessions** (`sys_session`) — better-auth session records (used to be
- in a separate ad-hoc table; now part of the platform schema).
-- **OAuth Apps** (`sys_oauth_application`) — third-party OAuth client
- registrations (e.g. for the Account portal).
-- **Signing Keys** (`sys_jwks`) — JWKS keys used for OIDC / JWT signing.
-- **Activity** (`sys_activity`) — high-level activity feed (audit log
- optimised for human reading; the technical audit trail still lives in
- `sys_audit_log`).
-- **Comments** (`sys_comment`) — user-authored comments on records, used
- by the Studio inspector and any object that opts into a comment thread.
-
-## Why it is static
-
-The previous `SetupPlugin`-based design existed because the referenced
-objects used to live in three different runtime plugins (auth / security
-/ audit), each of which registered its own Setup entries dynamically.
-Now that all `sys_*` objects are centralised in
-`@objectstack/platform-objects`, the navigation tree is also fixed at
-build time. Benefits:
-
-- One file to read to understand what Setup contains.
-- No load-order dependency between Setup and the plugins it references.
-- The artifact can be type-checked against `App` from
- `@objectstack/spec/ui` without any runtime plugin context.
+| **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` |
+| **Access Control** (`group_access_control`) | Roles / 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` |
+| **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) · Verifications · Device Codes · Identity Links · User Preferences — `platform-objects` |
+
+The exact rendered menu depends on which capability plugins are loaded.
+A few notable entries:
+
+- **Sessions** (`sys_session`) — better-auth session records, contributed
+ into `group_diagnostics`.
+- **OAuth Applications** (`sys_oauth_application`) — third-party OAuth
+ client registrations, contributed into `group_advanced`.
+- **Signing Keys (JWKS)** (`sys_jwks`) — JWKS keys used for OIDC / JWT
+ signing, contributed into `group_advanced`.
+- **Audit Logs** (`sys_audit_log`) — contributed by `plugin-audit` into
+ `group_diagnostics`. (The `sys_activity` and `sys_comment` objects also
+ live in `plugin-audit`, but they are not contributed as Setup nav
+ entries.)
+
+## Why a shell + contributions
+
+The Setup App is a shell of empty group anchors rather than a fixed
+navigation tree because the objects it surfaces are owned by different
+capability plugins (auth / security / sharing / audit / webhooks /
+approvals). Letting each package contribute its own menu entries keeps the
+menu for an object shipping with the package that owns the object — the
+UI-layer analog of object `extend`. Benefits:
+
+- The rendered menu always matches the loaded capabilities: a disabled
+ plugin contributes nothing and its slot stays empty.
+- No code in `setup.app.ts` needs to know about objects that live in other
+ packages; entries are merged by group id + priority on read.
+- The shell can still be type-checked against `App` from
+ `@objectstack/spec/ui`, and contributions against
+ `NavigationContribution`.
## Customising it
-The static export is intentionally read-only. To add a new entry:
-
-1. Add the object/dashboard/view to `@objectstack/platform-objects`
- (or to a project-local plugin if it is project-specific).
-2. Add a `navigation[]` leaf under the appropriate group in
- `packages/platform-objects/src/apps/setup.app.ts`.
-3. If the entry needs its own permission, declare it in
- `@objectstack/plugin-auth` so the `requiredPermissions` check picks it
+You do not edit `setup.app.ts` to add entries — it only holds the empty
+group anchors. To add a new entry:
+
+1. Register the object/dashboard in the package that owns it (or in a
+ project-local plugin if it is project-specific).
+2. Add a `NavigationContribution` targeting the relevant group id
+ (`app: 'setup'`, `group: 'group_*'`) — either in
+ `SETUP_NAV_CONTRIBUTIONS` for `@objectstack/platform-objects`-owned
+ objects, or in the `navigationContributions` of the plugin that owns
+ the object.
+3. If the entry needs its own permission, declare it where the relevant
+ permissions live (e.g. `setup.access` is granted in `plugin-security`'s
+ default permission sets) so the `requiredPermissions` check picks it
up.
For project-local Setup-style entries, define a separate App in your
diff --git a/content/docs/concepts/skills.mdx b/content/docs/concepts/skills.mdx
index 7e92c0029f..e173316623 100644
--- a/content/docs/concepts/skills.mdx
+++ b/content/docs/concepts/skills.mdx
@@ -60,12 +60,13 @@ Traditional AI code assistants generate generic code. They don't understand:
## Skill Architecture
-Each skill can use up to a **three-layer structure** inspired by [shadcn/ui](https://ui.shadcn.com/). Only `SKILL.md` is required; richer skills add the other two:
+Each skill ships `SKILL.md` plus a generated `references/` index, and richer skills layer optional `rules/` and `evals/` directories on top — up to four distinct parts. Only `SKILL.md` is required:
| Layer | File | Required | Purpose |
| :--- | :--- | :--- | :--- |
| **Overview** | `SKILL.md` | Yes | High-level guide with decision trees and quick-start examples |
-| **Rules** | `rules/*.md` | Optional | Detailed implementation rules with ✅ correct / ❌ incorrect code examples |
+| **References** | `references/_index.md` | Generated | Pointers into the published `@objectstack/spec` Zod sources (present in every skill except Formula) |
+| **Rules** | `rules/*.md` | Optional | Detailed implementation rules with ✅ correct / ❌ incorrect code examples (today: Data, Platform, Query) |
| **Evaluations** | `evals/*.md` | Optional | Test cases to validate AI assistant understanding |
### SKILL.md — The Entry Point
@@ -178,7 +179,7 @@ with name, email, industry, and annual_revenue fields.
### With Claude Code
-Claude Code reads `CLAUDE.md` at the repo root, which references all skills:
+Claude Code reads `AGENTS.md` at the repo root, which references all skills:
```
Read skills/objectstack-query/SKILL.md and help me build a query
diff --git a/content/docs/concepts/terminology.mdx b/content/docs/concepts/terminology.mdx
index 69119cf150..6320d12c1a 100644
--- a/content/docs/concepts/terminology.mdx
+++ b/content/docs/concepts/terminology.mdx
@@ -108,7 +108,7 @@ The fundamental unit of data modeling in ObjectStack. Roughly equivalent to a "T
### Driver
An adapter plugin in the Data Protocol runtime stack that allows the Data Layer to communicate with a specific underlying storage engine.
-* *Example:* `@objectstack/driver-sql` (Postgres / MySQL / SQLite via Knex), `@objectstack/driver-mongodb`, `@objectstack/driver-turso`.
+* *Example:* `@objectstack/driver-sql` (Postgres / MySQL / SQLite via Knex), `@objectstack/driver-mongodb`. `@objectstack/driver-turso` (edge SQLite) ships with ObjectStack Cloud rather than the open-source framework.
### AST (Abstract Syntax Tree)
The intermediate representation of a query or schema. The Data Protocol parses a JSON request into an AST before the Driver translates it into SQL/NoSQL queries. This allows for security validation and optimization before execution.
@@ -145,8 +145,8 @@ A map within the UI Runtime that links a string identifier (e.g., `"chart.bar"`)
## Governance
-### Space (Workspace)
-A logical isolation unit in the Cloud Protocol for multi-tenancy. A single ObjectStack instance can host multiple Spaces. Data is physically segregated by tenant isolation strategies.
+### Tenant (Space / Workspace)
+A logical isolation unit in the Cloud Protocol for multi-tenancy, modeled by the `tenant` schema (`cloud/tenant.zod.ts`). A single ObjectStack instance can host multiple tenants. Data is segregated by tenant isolation strategies. "Space" and "Workspace" are informal aliases for the same concept.
### FLS (Field-Level Security)
A granular permission model (Security Protocol) where access control is applied to individual fields (columns), not just the whole object (row).
diff --git a/content/docs/concepts/webhook-delivery.mdx b/content/docs/concepts/webhook-delivery.mdx
index cea5ff57cc..c312df08f3 100644
--- a/content/docs/concepts/webhook-delivery.mdx
+++ b/content/docs/concepts/webhook-delivery.mdx
@@ -5,15 +5,17 @@ description: How ObjectStack reliably ships outbound HTTP notifications — from
# Webhook Delivery
-> **Status:** Accepted · **Audience:** Plugin authors, runtime engineers,
+> **Status:** Shipped · **Audience:** Plugin authors, runtime engineers,
> integration partners
>
> **TL;DR** — Webhooks are the lingua franca of SaaS integration. ObjectStack
-> already defines a `Webhook` config schema; this document defines the
-> **delivery runtime** that turns config into actual HTTP requests reaching
-> external systems — durably, with signatures, with retries, and safely
-> across a cluster. Builds directly on the cluster primitives from
-> `cluster-semantics.mdx`.
+> defines a `Webhook` config schema; the **delivery runtime** that turns config
+> into actual HTTP requests is provided by `@objectstack/service-messaging`'s
+> shared outbound-HTTP outbox (`sys_http_delivery`). The
+> `plugin-webhooks` plugin owns only the `sys_webhook` configuration object and
+> an auto-enqueuer that fans record events onto that outbox; signing, retries,
+> dispatch, and idempotency are inherited from the messaging substrate
+> (ADR-0018 M3). This page documents the shipped behaviour.
## 1. Why this document exists
@@ -66,62 +68,82 @@ the standard object/view machinery.
## 3. Data model
-Two persisted objects underpin the runtime. Both are defined as
-ObjectStack `defineObject()` schemas so they participate in CRUD,
-permissions, audit, and Studio UI without bespoke code.
+Two persisted objects underpin the runtime, owned by different packages.
+`sys_webhook` (the configuration record) lives in `plugin-webhooks`;
+`sys_http_delivery` (the durable outbox) lives in
+`@objectstack/service-messaging` and is shared with the Flow `http` node.
+Both are defined as `ObjectSchema.create()` schemas so they participate in
+CRUD, permissions, audit, and Studio UI without bespoke code.
### 3.1 `sys_webhook`
-The subscription record. One row per "I want to receive events of type X
-at URL Y". Replaces the in-memory `WebhookSchema` for runtime use; the
-static config schema remains for `defineStack({ integrations })`-style
-declarative wiring.
-
-| Field | Type | Notes |
-|--------------------|-----------------------|------------------------------------------------------------|
-| `id` | uuid | Primary key. |
-| `owner_org_id` | string | Tenant / org owning the subscription. Required. |
-| `name` | snake_case identifier | Human-friendly name for the subscription. |
-| `url` | URL | Destination. Validated against egress allowlist on save. |
-| `events` | string[] | Event-type globs: `account.*`, `*.created`, `*`. |
-| `object_filter` | string[] ? | Optional object-name allowlist (`['account','contact']`). |
-| `headers` | record\ ? | Custom headers (no `authorization` — use `secret`). |
-| `secret_encrypted` | string | Encrypted shared secret. Never returned via API in clear. |
-| `secret_hint` | string | Last 4 chars of secret for UI display (`whsec_…1234`). |
-| `retry_policy` | json | `{ maxAttempts, initialDelayMs, maxDelayMs, backoff }`. |
-| `timeout_ms` | int | Per-attempt timeout. Default 30s. |
-| `enabled` | boolean | Soft toggle; disables delivery without losing history. |
-| `created_by` | string | User id of creator. |
-| `created_at` | timestamp | Standard audit columns. |
-| `updated_at` | timestamp | |
-
-### 3.2 `sys_webhook_delivery`
-
-The outbox + audit. One row per attempt-cycle. Persisted **before** any
-HTTP call; updated after each attempt. This table is the durability
-boundary — if it has a `pending` row, the dispatcher will eventually
-deliver it.
-
-| Field | Type | Notes |
-|------------------|-----------------------|--------------------------------------------------------|
-| `id` | uuid | Primary key. Also used as the receiver-side idempotency key. |
-| `webhook_id` | uuid | FK → `sys_webhook`. |
-| `event_id` | string | Origin event id (correlation across deliveries). |
-| `event_type` | string | E.g. `account.updated`. |
-| `payload` | json | The full payload that will be POSTed. |
-| `status` | enum | `pending` / `in_flight` / `success` / `failed` / `dead`. |
-| `attempts` | int | Count of HTTP requests issued. |
-| `claimed_by` | string ? | Node id holding the row (NULL when not in flight). |
-| `claimed_at` | timestamp ? | When the claim was taken. |
-| `next_retry_at` | timestamp ? | When the next retry is eligible. NULL once dead. |
-| `last_attempted_at` | timestamp ? | Most recent attempt start. |
-| `response_code` | int ? | Last HTTP status code received. |
-| `response_headers` | json ? | Truncated to first ~4 KB. |
-| `response_body` | text ? | Truncated to first ~16 KB. |
-| `duration_ms` | int ? | Last attempt round-trip. |
-| `error` | text ? | Last transport-level error (DNS, connect, timeout). |
-| `created_at` | timestamp | |
-| `updated_at` | timestamp | |
+The subscription record. One row per "I want webhook X to fire for object Y".
+The full transport configuration (headers, secret, timeout, method) is carried
+in `definition_json`, a serialised `Webhook` JSON (canonical schema:
+`WebhookSchema` in `@objectstack/spec/automation/webhook`).
+
+| Field | Type | Notes |
+|-------------------|-----------|------------------------------------------------------------------------------------|
+| `id` | text | Primary key. |
+| `name` | text | Unique snake_case name — referenced in logs and audit. |
+| `label` | text | Optional display label. |
+| `object_name` | text | Short object name whose events fire this webhook (blank = manual / API-triggered). |
+| `triggers` | text | Comma-separated event list: `create,update,delete,undelete,api`. |
+| `url` | text | External endpoint that receives the POST. |
+| `method` | text | HTTP method. Default `POST`. |
+| `description` | textarea | Free-text description. |
+| `active` | boolean | Inactive webhooks are skipped by the dispatcher. Default `true`. |
+| `definition_json` | textarea | Serialised `Webhook` JSON (`@objectstack/spec/automation/webhook`) — carries the full headers / auth / retry / payload config, including the signing `secret`, custom `headers`, and `timeoutMs`. |
+| `created_at` | datetime | Standard audit columns. |
+| `updated_at` | datetime | |
+
+Matching at runtime is purely `object_name` + the comma-separated `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
+`secret_hint` — see §6 for the actual signing model and §11 for the (single)
+retry budget.
+
+### 3.2 `sys_http_delivery`
+
+The outbox + audit. One row per delivery. Persisted **before** any HTTP
+call; updated after each attempt. This table is the durability boundary —
+if it has a `pending` row, the dispatcher will eventually deliver it.
+
+It is **not** webhook-specific: `sys_http_delivery` is owned by
+`@objectstack/service-messaging` (ADR-0018 M3) and shared by the Flow `http`
+node executor and webhook fan-out, so both inherit retry / idempotency /
+dead-letter from one substrate. Webhook deliveries are the rows with
+`source = 'webhook'`. It generalises the old design's per-webhook table:
+`webhook_id` → `ref_id`, `event_id` → `dedup_key`, `event_type` → `label`,
+`secret` → `signing_secret`. Rows are managed by the platform and not directly
+writable.
+
+| Field | Type | Notes |
+|-------------------|----------|-----------------------------------------------------------------------------|
+| `id` | text | Primary key. Also doubles as the receiver-side idempotency key. |
+| `source` | text | Provenance domain, e.g. `webhook` \| `flow`. `UNIQUE(source, dedup_key)`. |
+| `ref_id` | text | Partition / ordering anchor within source (for webhooks, the webhook id). |
+| `dedup_key` | text | `UNIQUE(source, dedup_key)` for at-most-once enqueue. |
+| `label` | text | Diagnostic label / event type — surfaced on `X-Objectstack-Event`. |
+| `url` | text | Target URL, snapshotted at enqueue so config edits do not rewrite live rows. |
+| `method` | text | HTTP method. |
+| `headers_json` | textarea | Custom headers, serialised. |
+| `signing_secret` | text | HMAC secret used for the signature header (see §6). |
+| `timeout_ms` | number | Per-attempt timeout. |
+| `payload_json` | textarea | The full payload that will be POSTed. |
+| `partition_key` | number | `hash(ref_id) mod partitionCount`, precomputed for cheap `WHERE`. |
+| `status` | text | `pending` / `in_flight` / `success` / `failed` / `dead`. |
+| `attempts` | number | Count of HTTP requests issued. |
+| `claimed_by` | text | Node id holding the row. |
+| `claimed_at` | number | When the claim was taken (epoch ms). |
+| `next_retry_at` | number | When the next retry is eligible (epoch ms). |
+| `last_attempted_at` | number | Most recent attempt start (epoch ms). |
+| `response_code` | number | Last HTTP status code received. |
+| `response_body` | textarea | Truncated to the first 16 KB. |
+| `error` | textarea | Last transport-level error (DNS, connect, timeout). |
+| `created_at` | number | Epoch ms. |
+| `updated_at` | number | Epoch ms. |
> **Why store full payload?** Receivers may be down for hours; we must
> retry the *exact* bytes we promised to send. Recomputing payload from
@@ -144,13 +166,13 @@ Five stages, each implemented as a thin layer over an existing primitive.
└──────────────────────────┬──────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
-│ 3. Persist INSERT sys_webhook_delivery (status=pending) │
+│ 3. Persist INSERT sys_http_delivery (status=pending) │
│ ── this is the durability boundary ── │
└──────────────────────────┬──────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
-│ 4. Dispatch worker claims with │
-│ SELECT … FOR UPDATE SKIP LOCKED LIMIT N; │
+│ 4. Dispatch worker holds a per-partition cluster lock, then │
+│ atomic UPDATE pending→in_flight claims a batch; │
│ issues POST; writes back result │
└──────────────────────────┬──────────────────────────────────────┘
▼
@@ -180,55 +202,55 @@ running on every node is fine because the INSERT in stage 3 is keyed by
For each incoming event the subscriber:
-1. Loads `sys_webhook` rows where `enabled = true` AND
- `(events globs match event_type)` AND
- `(object_filter is NULL OR object_filter contains event.object)`.
-2. For each match, builds the canonical payload (§5).
-3. Inserts a `sys_webhook_delivery` row.
+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`.
+2. For each match, builds the payload (§5).
+3. Enqueues a `sys_http_delivery` row (`source = 'webhook'`).
### 4.3 Stage 3 — Persist
-A single `INSERT … ON CONFLICT DO NOTHING` per match. The
-`(event_id, webhook_id)` uniqueness constraint absorbs duplicate event
-deliveries from `at-least-once` semantics. After this stage the event
-producer is no longer involved; the rest is the dispatcher's problem.
+A single enqueue per match. The `(source, dedup_key)` uniqueness
+constraint — where `dedup_key` is `::::`
+— absorbs duplicate event deliveries from `at-least-once` semantics. After this
+stage the event producer is no longer involved; the rest is the dispatcher's
+problem.
### 4.4 Stage 4 — Dispatcher worker
-The dispatcher is a `cluster`-scoped service with
-`leaderStrategy: 'partitioned'`. Each node owns a hash-partition of
-`webhook_id`s, so the same webhook's deliveries always land on the same
-node — useful for per-receiver rate limiting and connection reuse.
+`HttpDispatcher` (in `@objectstack/service-messaging`) ticks on a timer and,
+for each partition, attempts to acquire a **per-partition cluster lock**
+(`http.dispatcher.partition.`). Partition affinity is on the delivery's
+`ref_id` (the webhook id), so the same webhook's deliveries always land on the
+same partition — useful for in-order delivery and connection reuse. On a
+single-node runtime the lock is an always-grant stub.
-Loop body, run every 250ms on each node:
+Within a held partition, the lock-holder claims a batch with an **atomic
+conditional UPDATE** rather than `SELECT … FOR UPDATE SKIP LOCKED`:
```sql
-UPDATE sys_webhook_delivery
-SET status = 'in_flight',
- claimed_by = $node_id,
- claimed_at = now(),
- attempts = attempts + 1,
- last_attempted_at = now()
-WHERE id IN (
- SELECT id FROM sys_webhook_delivery
- WHERE status = 'pending'
- AND (next_retry_at IS NULL OR next_retry_at <= now())
- AND hashtext(webhook_id::text) % $partition_count = $partition_index
- ORDER BY next_retry_at NULLS FIRST
- LIMIT $batch_size
- FOR UPDATE SKIP LOCKED
-)
-RETURNING *;
+-- 1. select candidate ids for this partition
+SELECT id FROM sys_http_delivery
+WHERE status = 'pending'
+ AND partition_key = $partition_index
+ AND (next_retry_at IS NULL OR next_retry_at <= $now)
+ORDER BY next_retry_at
+LIMIT $batch_size;
+
+-- 2. atomic claim: only rows still pending flip to in_flight
+UPDATE sys_http_delivery
+SET status = 'in_flight', claimed_by = $node_id, claimed_at = $now
+WHERE id IN (...) AND status = 'pending';
```
-`FOR UPDATE SKIP LOCKED` is the single mechanism that prevents two
-nodes from delivering the same row. It is provided by Postgres natively
-and emulated in the SQL driver layer for other engines.
+The `WHERE … AND status = 'pending'` predicate on the UPDATE is the
+exactly-once claim: two workers racing on the same row cannot both flip it.
+The held partition lock keeps the race window small in the first place.
-After each HTTP attempt the row is updated with the result. If the
-request succeeded (2xx), `status = 'success'`. Otherwise `status` returns
-to `pending` (with bumped `next_retry_at`) or, if `attempts >=
-retry_policy.maxAttempts`, becomes `dead`.
+After each HTTP attempt the row is updated with the result. If the request
+succeeded (2xx), `status = 'success'`. Otherwise `status` returns to `pending`
+(with bumped `attempts` and `next_retry_at`) or, once the retry budget is
+exhausted, becomes `dead`.
### 4.5 Stage 5 — Retry backoff
@@ -255,147 +277,99 @@ same time.
## 5. Payload format
-Stable across versions. Receivers parse it once and never need to change
-their parser when we add new event types.
+The POST body is the realtime event payload with a small fixed prefix merged
+on top:
```json
{
- "id": "evt_01HXXXXXXXXXXXXXX",
- "type": "account.updated",
- "created": 1730000000,
- "api_version": "2026-05-23",
"object": "account",
- "tenant_id": "tnt_abc",
- "data": {
- "current": { "id": "acc_123", "name": "Acme Inc.", ... },
- "previous": { "id": "acc_123", "name": "Acme Co.", ... }
- },
- "actor": {
- "type": "user",
- "id": "usr_456",
- "label":"Jane Doe"
- },
- "context": {
- "request_id": "req_789",
- "correlation_id": "corr_abc",
- "project_id": "proj_xyz",
- "environment": "production"
- }
+ "recordId": "acc_123",
+ "action": "updated",
+ "timestamp": 1730000000000
+ // ...remaining fields from the originating event payload
}
```
Notes:
-- **`id`** — globally unique event id. Also the `sys_webhook_delivery.id`
- for this attempt; receivers use it as their idempotency key.
-- **`type`** — dotted `{object}.{operation}`. Stable enum; new types are
- additive.
-- **`api_version`** — calendar versioning (`YYYY-MM-DD`). Webhook
- subscriptions can pin a version so payload-shape changes are opt-in,
- not forced.
-- **`data.previous`** — present on `updated` events; `null` elsewhere.
-- **`actor`** — distinguishes user-initiated changes from system /
- flow / agent / API-key origins.
-- **`context.request_id`** — links the webhook back to the originating
- REST call in audit logs and traces.
+- **`object`** — short object name the event came from.
+- **`recordId`** — id of the affected record.
+- **`action`** — `created` / `updated` / `deleted` / `undeleted`.
+- **`timestamp`** — event timestamp (epoch ms).
+- Any additional fields the event carried (e.g. record snapshot data) are
+ spread in after these four.
+
+Idempotency and event correlation are carried in **headers**, not the body.
+Every attempt sends:
+
+| Header | Meaning |
+|---------------------------|-----------------------------------------------------------------|
+| `X-Objectstack-Delivery` | The `sys_http_delivery` row id — use as the idempotency key. |
+| `X-Objectstack-Attempt` | 1-based attempt number for this delivery. |
+| `X-Objectstack-Event` | The event type / label (when present). |
+| `X-Objectstack-Signature` | HMAC signature (see §6), when a signing secret is configured. |
+
+Requests also set `Content-Type: application/json` and
+`User-Agent: ObjectStack-Http/1.0`.
## 6. Signing & verification
-Adapted from Stripe's signing scheme. Industry standard, well-documented
-on the receiver side, includes replay protection.
+GitHub-style HMAC over the raw request body. When the webhook's
+`definition_json` carries a `secret`, every outbound request is signed.
### 6.1 Outbound header
```
-X-ObjectStack-Signature:
- t=1730000000,
- v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
+X-Objectstack-Signature: sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```
-Where:
-
-- `t` is the Unix timestamp (seconds) when the signature was generated.
-- `v1` is `HEX( HMAC-SHA256( webhook.secret, "{t}.{request_body}" ) )`.
-
-The `v` prefix (here `v1`) is a version marker — future signature
-schemes (e.g. SHA-3, Ed25519) ship as `v2=…`, `v3=…` and may be sent
-alongside `v1` during transitions.
+Where the hex value is `HMAC-SHA256( secret, raw_request_body )`. There is no
+timestamp, no `{t}.{body}` concatenation, no version (`v1`) prefix, and no
+replay window — the signature covers the body bytes only.
### 6.2 Receiver-side verification
Receivers MUST:
-1. Read `t` and `v1` from the header.
-2. Reject the request if `abs(now - t) > 300` (5 minutes — replay window).
-3. Recompute `HMAC-SHA256(secret, "{t}.{raw_body}")` over the raw request
- body bytes (no JSON re-encode — whitespace matters).
-4. Compare with `v1` in **constant time** (e.g. `hmac.compare_digest`).
-5. Reject if not equal.
-
-A reference verifier in TypeScript, Python, and Go ships in the
-documentation site under `guides/webhook-receivers/`.
-
-### 6.3 Secret rotation
-
-`POST /api/v1/webhooks/:id/rotate` generates a new secret, returns it
-**once** in the response, and stores both old and new for a 24-hour
-grace period. During grace, outbound requests are signed with the new
-secret but the API returns the old secret's `secret_hint` so customers
-can identify which secret was active when a delivery was made.
+1. Read the `sha256=` value from `X-Objectstack-Signature`.
+2. Recompute `HMAC-SHA256(secret, raw_body)` over the raw request body bytes
+ (no JSON re-encode — whitespace matters).
+3. Compare in **constant time** (e.g. `hmac.compare_digest`).
+4. Reject if not equal.
-## 7. Egress safety (SSRF defence)
+Use the `X-Objectstack-Delivery` header (the delivery row id) as the
+idempotency key when deduplicating retries.
-Webhooks are a textbook SSRF vector: an internal user can point a webhook
-at `http://169.254.169.254/latest/meta-data/` and exfiltrate cloud
-credentials through the response body in `sys_webhook_delivery`. The
-runtime defends in three layers.
+## 7. Egress safety
-### 7.1 URL validation at save time
+> **Not yet implemented.** URL blocklisting (localhost / RFC 1918 / link-local /
+> cloud-metadata rejection, `allowPrivate`, https-only enforcement) and
+> DNS-rebinding re-validation are **not** part of the shipped runtime. The
+> sender POSTs to the configured URL with no SSRF filtering. Treat webhook URL
+> configuration as a privileged operation and gate it with RBAC accordingly;
+> network-level egress filtering is future work.
-`sys_webhook.url` is validated against a blocklist on insert/update:
+### 7.1 Response body capping
-- Reject `localhost`, `127.0.0.0/8`, `::1`.
-- Reject RFC 1918 private ranges (`10.0.0.0/8`, `172.16.0.0/12`,
- `192.168.0.0/16`) unless `cluster.egress.allowPrivate = true`.
-- Reject link-local (`169.254.0.0/16`) and cloud-metadata IPs
- (`169.254.169.254` AWS/Azure/GCP, `100.100.100.200` Alibaba) **always**.
-- Reject schemes other than `https://` in production; `http://` allowed
- only when `runtime.mode === 'development'`.
+The recorded `response_body` is truncated to 16 KB before persistence. This is
+the one egress safeguard that is implemented today: it bounds the worst-case
+exfiltration through a single delivery row even if a hostile URL is configured.
-### 7.2 DNS-time re-validation
-
-DNS responses are re-checked at request time, not just at save time, to
-defeat DNS rebinding attacks. Resolved IPs that fall in a blocked range
-abort the request with `status = failed, error = 'dns_rebound'`.
-
-### 7.3 Response body capping
+## 8. REST surface
-`response_body` is truncated to 16 KB before persistence. This bounds
-the worst-case exfiltration even if the URL slips through validation —
-an attacker cannot pull megabytes of internal data through a single
-delivery row.
+CRUD for `sys_webhook` (and read access to `sys_http_delivery`) comes for free
+from the generic object/REST surface — there is no bespoke webhook CRUD router.
+The plugin mounts exactly **one** custom endpoint:
-## 8. REST surface
+| Method | Path | Body | Purpose |
+|--------|-----------------------------|-------------------|--------------------------------------------------|
+| POST | `/api/v1/webhooks/redeliver` | `{ deliveryId }` | Re-queue a previously failed/dead delivery. |
-All endpoints under `/api/v1/webhooks/`. CRUD comes for free from the
-`sys_webhook` object; the table below lists the dispatcher-specific
-extensions.
-
-| Method | Path | Purpose |
-|--------|-----------------------------------------------|--------------------------------------------------------|
-| GET | `/webhooks` | List subscriptions for the current org. |
-| POST | `/webhooks` | Create. Server generates `secret`, returns it **once**. |
-| GET | `/webhooks/:id` | Read. `secret` is never returned — only `secret_hint`. |
-| PATCH | `/webhooks/:id` | Update url / events / filters / retry policy. |
-| DELETE | `/webhooks/:id` | Delete. Pending deliveries are cancelled. |
-| POST | `/webhooks/:id/test` | Send a synthetic payload to verify connectivity. |
-| POST | `/webhooks/:id/rotate` | Rotate the signing secret (24h grace). |
-| GET | `/webhooks/:id/deliveries` | Paginated delivery history with filters. |
-| GET | `/webhooks/:id/deliveries/:delivery_id` | Full request/response for one attempt-cycle. |
-| POST | `/webhooks/:id/deliveries/:delivery_id/redeliver` | Re-queue a previously failed/dead delivery. |
-
-The CRUD endpoints are auto-generated from the `sys_webhook` object
-schema; only the last five require custom handlers in `plugin-webhooks`.
+It delegates to `messaging.redeliverHttp(deliveryId)` and is guarded by the
+better-auth session (any authenticated user). Responses: `200` on success,
+`401` unauthenticated, `400` missing/invalid body, `404` `not_found`, `409`
+`not_eligible`. There are no `/webhooks/:id/test`, `/rotate`,
+`/deliveries`, or per-delivery redeliver routes.
## 9. Concurrency & rate-limiting
@@ -411,35 +385,31 @@ receivers from being driven into 429s by our bulk traffic.
## 10. Observability
-Three views into the runtime:
-
-1. **`sys_webhook_delivery` table** — the source of truth. Standard
- ObjectStack list/detail views show status, latency, response, retry
- schedule. Studio surfaces this under the webhook's detail page.
-2. **OpenTelemetry spans** — every dispatch attempt opens a span with
- `webhook.id`, `webhook.event_type`, `webhook.attempt`,
- `http.url`, `http.status_code`. Spans link to the parent request via
- `context.correlation_id` so a webhook delivery can be traced back to
- the user click that produced the event.
-3. **Prometheus metrics** — exposed by the dispatcher:
- - `webhook_deliveries_total{status}` counter
- - `webhook_delivery_duration_ms` histogram
- - `webhook_queue_depth{partition}` gauge
- - `webhook_dead_total{webhook_id}` counter (alertable)
+1. **`sys_http_delivery` table** — the source of truth. It ships standard
+ ObjectStack list views (`Recent`, `Failures`, `Pending`) showing source,
+ url, status, attempts, response code, and timestamps; Studio surfaces these
+ under the **HTTP Deliveries** nav. The `X-Objectstack-Signature` header
+ (note the lowercase `s` in `stack`), `X-Objectstack-Delivery`,
+ `X-Objectstack-Event`, and `X-Objectstack-Attempt` headers correlate a
+ received request back to its row.
+2. **`onAttempt` hook** — `HttpDispatcher` fires an `onAttempt(delivery,
+ success)` callback after every attempt, the one programmatic
+ observability hook. Wire it to whatever metrics/tracing backend your
+ deployment uses.
+
+> **Future work.** Built-in OpenTelemetry spans and Prometheus metrics are
+> not yet emitted; the `onAttempt` hook is the integration point until they
+> are.
## 11. Multi-tenancy
-Every `sys_webhook` row is owned by an `owner_org_id`. The protocol
-enforces:
-
-- **Read isolation** — users can only see webhooks belonging to orgs
- they have membership in. Enforced by the standard RBAC layer.
-- **Egress isolation** — when `cluster.tenantIsolation = 'channel-prefix'`,
- dispatcher partitioning includes `(org_id, webhook_id)` so one tenant's
- bulk import cannot starve another tenant's webhook throughput on the
- same node.
-- **Quota** — `sys_webhook.retry_policy.maxAttempts` is capped by the
- tenant plan; free plans get `maxAttempts ≤ 3`, paid plans up to 12.
+> **Not yet implemented.** `sys_webhook` has no `owner_org_id` column, there is
+> no `retry_policy` field, and dispatcher partitioning is on `ref_id` (the
+> webhook id) only — there is no `(org_id, webhook_id)` partitioning or
+> per-tenant egress isolation. The retry budget is a single fixed 7-step
+> schedule (§4.5), not a per-plan quota. Per-tenant webhook ownership and
+> isolation are future work; until then, isolate access at the RBAC layer on
+> `sys_webhook`.
## 12. Failure modes & guarantees
@@ -448,8 +418,8 @@ A precise table of what the runtime promises and what it does not.
| Failure | Guarantee |
|------------------------------------------|-------------------------------------------------------------|
| Producer node crashes mid-emit | Event durably in transport bus (at-least-once), redelivered on producer restart. |
-| Subscriber node crashes after persist | Row exists in `sys_webhook_delivery`, another node picks it up. |
-| Dispatcher node crashes mid-HTTP | Row stays `in_flight` with `claimed_by`; reaper re-pends it after `claim_ttl` (default 60s). |
+| Subscriber node crashes after persist | Row exists in `sys_http_delivery`, another node picks it up. |
+| Dispatcher node crashes mid-HTTP | Row stays `in_flight` with `claimed_by`; it reverts to `pending` after the claim TTL and is re-posted. The TTL derives from the dispatcher tick (`intervalMs`, default 500ms): `lockTtlMs = 5 × intervalMs`, `claimTtlMs = 2 × lockTtlMs` (so ~5s at defaults), all configurable via `HttpDispatcherOptions`. |
| Receiver returns 5xx | Retry per backoff schedule until `maxAttempts`. |
| Receiver returns 4xx | Treated as terminal — no retry, status `dead` immediately. Exception: 408 / 429 are retried. |
| Receiver returns 2xx | `status = success`, no more attempts. |
@@ -457,48 +427,47 @@ A precise table of what the runtime promises and what it does not.
| Timeout (per-attempt) | Treated as 5xx — retry per backoff. |
| Network partition between dispatcher and DB | Worker pauses; row stays `pending`. On reconnect, normal claim resumes. |
| Two events for the same record arrive out of order | `partitionKey` ordering at the event bus prevents this. Receiver still sees deliveries in emit order. |
-| Duplicate delivery (at-least-once) | Receiver gets the same `evt_…` id twice. Must dedupe — we provide the key, can't enforce. |
+| Duplicate delivery (at-least-once) | Receiver gets the same `X-Objectstack-Delivery` id twice. Must dedupe on that header — we provide the key, can't enforce. |
## 13. Plugin location & layering
```
packages/plugins/plugin-webhooks/
├── src/
-│ ├── plugin.ts # lifecycle hooks (onEnable wires subscriber + dispatcher)
-│ ├── objects/
-│ │ ├── sys_webhook.ts # defineObject() — schema, RBAC, indexes
-│ │ └── sys_webhook_delivery.ts # defineObject() — schema, RBAC, indexes
-│ ├── subscriber.ts # EventBus subscriber → INSERT pending rows
-│ ├── dispatcher.ts # cluster-scoped service, partitioned, drains queue
-│ ├── signer.ts # HMAC-SHA256 signing, version v1
-│ ├── egress.ts # URL validation + DNS re-check + cap
-│ ├── reaper.ts # cluster-singleton leader-elected: reclaim stuck in_flight rows
-│ └── rest/
-│ ├── test.ts # POST /webhooks/:id/test
-│ ├── rotate.ts # POST /webhooks/:id/rotate
-│ ├── deliveries.ts # GET /webhooks/:id/deliveries
-│ └── redeliver.ts # POST /webhooks/:id/deliveries/:did/redeliver
+│ ├── webhook-outbox-plugin.ts # plugin: registers sys_webhook + nav,
+│ │ # starts the auto-enqueuer, mounts redeliver
+│ ├── sys-webhook.object.ts # ObjectSchema.create() — sys_webhook config
+│ ├── auto-enqueuer.ts # realtime data.record.* → enqueue onto outbox
+│ └── schema.ts # shared types
└── test/
└── …
```
+The delivery runtime — `sys_http_delivery`, `HttpDispatcher`, the HTTP sender,
+signing, retry classification — lives in `@objectstack/service-messaging`, not
+in this plugin. `plugin-webhooks` owns only the `sys_webhook` configuration
+object and the auto-enqueuer that fans record events onto the shared outbox.
+
The plugin depends on:
-- `@objectstack/spec` for `WebhookSchema` (existing) and cluster
- primitives (new in `cluster-semantics.mdx`).
-- `service-cluster` for `pubsub` / `lock` / `kv`.
-- `service-queue` (optional) — if present, the dispatcher delegates queue
- storage to it; if absent, it uses the `sys_webhook_delivery` table
- directly with `FOR UPDATE SKIP LOCKED`.
+- `@objectstack/core`
+- `@objectstack/service-messaging` (declared as plugin dependency
+ `com.objectstack.service.messaging`) — provides the outbox object and
+ dispatcher.
+- `@objectstack/spec` for `WebhookSchema`.
+
+There is no dependency on `service-cluster` or `service-queue`; cross-node
+coordination is the dispatcher's per-partition cluster lock inside
+service-messaging.
## 14. Industry alignment
Every design choice matches one or more established systems:
-- **Stripe webhooks** — signing scheme (`t=…,v1=…`), 5-minute replay
- window, retry schedule shape, signed-secret rotation.
-- **GitHub webhooks** — event-type globs, redelivery UI, per-delivery
- detail page with request/response bytes.
+- **Stripe webhooks** — retry schedule shape (1s → 24h, ~1.5-day window) with
+ jitter.
+- **GitHub webhooks** — `X-…-Signature: sha256=` signing
+ scheme, redelivery, per-delivery detail with request/response bytes.
- **Shopify webhooks** — HMAC-SHA256 header, `X-Shopify-Topic` event
hint (we use `type` in payload instead — simpler).
- **AWS EventBridge / SQS** — at-least-once durability, dead-letter
@@ -510,30 +479,41 @@ Every design choice matches one or more established systems:
Three phases. Each phase delivers visible user value.
+> **How it actually shipped.** The implementation did **not** introduce new
+> `system/*` Zod schemas. There is no `system/webhook-delivery.zod.ts` and no
+> `SysWebhookSchema` / `SysWebhookDeliverySchema` /
+> `WebhookSignatureHeaderSchema` / `WebhookEventPayloadSchema`. Instead it
+> reused the existing `WebhookSchema` (and `WebhookReceiverSchema`) in
+> `automation/webhook.zod.ts`, defined `sys_webhook` as an
+> `ObjectSchema.create()` in the plugin, and reused
+> service-messaging's `sys_http_delivery` outbox for deliveries (ADR-0018 M3).
+> The phase list below is the original plan; read it as design intent, not the
+> current artifact layout.
+
### Phase 1 — Spec (~1 day)
-1. `system/webhook-delivery.zod.ts` (new) — `SysWebhookSchema`,
- `SysWebhookDeliverySchema`, `WebhookSignatureHeaderSchema`,
- `WebhookEventPayloadSchema`. These are persistence schemas distinct
- from the existing `automation/webhook.zod.ts` config schema.
-2. Documentation cross-links between the two ("static config" vs
- "runtime subscription").
+1. Persistence + config schemas. (As shipped: `sys_webhook` is an
+ `ObjectSchema`, deliveries reuse `sys_http_delivery`, and the config schema
+ is the existing `WebhookSchema`.)
+2. Documentation cross-links between "static config" and "runtime
+ subscription".
-### Phase 2 — Plugin skeleton + memory dispatcher (~2 days)
+### Phase 2 — Plugin skeleton + dispatcher (~2 days)
-- `plugin-webhooks` package with the object definitions wired.
-- In-process dispatcher using `service-cluster` memory driver.
-- Sign + verify + egress safety + retry backoff working.
-- Manual `POST /webhooks/:id/test` proves the path end-to-end.
+- `plugin-webhooks` package with `sys_webhook` wired and the auto-enqueuer
+ fanning record events onto the shared outbox.
+- Signing + retry backoff working through service-messaging's HTTP sender.
### Phase 3 — Production dispatcher (~2 days)
-- `FOR UPDATE SKIP LOCKED` claim loop (Postgres driver).
-- Reaper for stuck `in_flight` rows.
-- Partitioned cluster scope.
-- Prometheus metrics + OTel spans.
-- Studio UI: deliveries list, detail drawer, redeliver button (reuses
+- Atomic conditional-UPDATE claim loop under a per-partition cluster lock
+ (as shipped; the original plan called for `FOR UPDATE SKIP LOCKED`).
+- Stuck `in_flight` rows revert after the claim TTL.
+- Partitioned dispatch on `ref_id`.
+- Studio UI: HTTP Deliveries list views + redeliver endpoint (reuses
existing object/view machinery — zero custom React).
+- *Future:* built-in Prometheus metrics + OTel spans (today only the
+ `onAttempt` hook).
### Phase 4 — Hardening (opportunistic)
diff --git a/content/docs/getting-started/architecture.mdx b/content/docs/getting-started/architecture.mdx
index 388c9cf900..ceb9c5651f 100644
--- a/content/docs/getting-started/architecture.mdx
+++ b/content/docs/getting-started/architecture.mdx
@@ -83,7 +83,7 @@ ObjectStack enforces **Separation of Concerns** through protocol boundaries:
### Example: Defining a Customer Object
```typescript
-// packages/crm/src/objects/customer.object.ts
+// src/objects/customer.object.ts
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const Customer = ObjectSchema.create({
@@ -143,65 +143,60 @@ That's the job of the other layers.
### Example: Permission Rules
```typescript
-// packages/crm/src/permissions/customer.permission.ts
-import { Permission } from '@objectstack/spec';
-
-export const CustomerPermission = Permission({
- object: 'customer',
- rules: [
- {
- profile: 'sales_rep',
- crud: {
- create: true,
- read: true,
- update: true,
- delete: false, // Only managers can delete
- },
- fieldPermissions: {
- annual_revenue: { read: true, edit: false }, // Read-only
- },
- },
- {
- profile: 'sales_manager',
- crud: {
- create: true,
- read: true,
- update: true,
- delete: true,
- },
+// src/permissions/sales_rep.permission.ts
+import type { PermissionSet } from '@objectstack/spec';
+
+// A permission set (here used as a profile) keyed by object and field.
+export const SalesRepPermission: PermissionSet = {
+ name: 'sales_rep',
+ label: 'Sales Rep',
+ isProfile: true,
+ objects: {
+ customer: {
+ allowCreate: true,
+ allowRead: true,
+ allowEdit: true,
+ allowDelete: false, // Only managers can delete
},
- ],
-});
+ },
+ fields: {
+ // . -> field-level security
+ 'customer.annual_revenue': { readable: true, editable: false }, // Read-only
+ },
+};
```
### Example: Workflow Automation
```typescript
-// packages/crm/src/workflows/customer.workflow.ts
-import { Workflow } from '@objectstack/spec';
-
-export const CustomerWorkflow = Workflow({
- object: 'customer',
- trigger: 'after_create',
- conditions: [
- { field: 'annual_revenue', operator: 'greaterThan', value: 1000000 },
+// src/flows/high_value_customer.flow.ts
+import { defineFlow } from '@objectstack/spec';
+
+// Automation is authored as a Flow: a graph of nodes connected by edges.
+// A record_change flow runs when records of its target object change; branch
+// edges carry CEL conditions evaluated against the changed record.
+export const HighValueCustomerFlow = defineFlow({
+ name: 'high_value_customer',
+ label: 'High-Value Customer Alert',
+ type: 'record_change',
+ status: 'active',
+ nodes: [
+ { id: 'start', type: 'start', label: 'Customer created' },
+ { id: 'assign', type: 'update_record', label: 'Assign owner' },
+ { id: 'notify', type: 'send_email', label: 'Alert leadership' },
+ { id: 'end', type: 'end', label: 'End' },
],
- actions: [
- {
- type: 'assign_owner',
- params: { owner: 'enterprise_sales_team' },
- },
- {
- type: 'send_email',
- params: {
- template: 'high_value_customer_alert',
- to: 'sales-leadership@company.com',
- },
- },
+ edges: [
+ // Only continue when annual revenue exceeds $1M.
+ { id: 'e1', source: 'start', target: 'assign', condition: 'record.annual_revenue > 1000000' },
+ { id: 'e2', source: 'assign', target: 'notify' },
+ { id: 'e3', source: 'notify', target: 'end' },
],
});
```
+See the [Automation Protocol](/docs/protocol/objectos) for the full Flow node and edge reference.
+
ObjectOS **orchestrates** these rules at runtime, independent of the data structure or UI.
## Layer 3: ObjectUI (View Protocol)
@@ -220,63 +215,50 @@ ObjectOS **orchestrates** these rules at runtime, independent of the data struct
### Example: List View
```typescript
-// packages/crm/src/views/customer_list.view.ts
-import { ListView } from '@objectstack/spec';
-
-export const CustomerListView = ListView({
- object: 'customer',
- label: 'All Customers',
- type: 'grid',
- columns: [
- { field: 'name', width: 200 },
- { field: 'industry', width: 150 },
- { field: 'annual_revenue', width: 150 },
- { field: 'primary_contact', width: 180 },
- ],
- filters: [
- { field: 'industry', operator: 'equals' },
- { field: 'annual_revenue', operator: 'greaterThan' },
- ],
- defaultSort: { field: 'name', direction: 'asc' },
+// src/views/customer.view.ts
+import { defineView } from '@objectstack/spec';
+
+// A view is authored with defineView({ list, form }); the list/form configs
+// are nested, and the data source declares which object the view reads.
+export const CustomerView = defineView({
+ list: {
+ type: 'grid',
+ data: { provider: 'object', object: 'customer' },
+ columns: [
+ { field: 'name' },
+ { field: 'industry' },
+ { field: 'annual_revenue' },
+ { field: 'primary_contact' },
+ ],
+ filterableFields: ['industry', 'annual_revenue'],
+ sort: [{ field: 'name', order: 'asc' }],
+ },
});
```
### Example: Form View
```typescript
-// packages/crm/src/views/customer_form.view.ts
-import { FormView } from '@objectstack/spec';
-
-export const CustomerFormView = FormView({
- object: 'customer',
- label: 'Customer Details',
- type: 'tabbed',
- tabs: [
- {
- label: 'Overview',
- sections: [
- {
- label: 'Company Information',
- fields: ['name', 'industry', 'annual_revenue'],
- },
- {
- label: 'Contact',
- fields: ['primary_contact'],
- },
- ],
- },
- {
- label: 'Related Records',
- sections: [
- {
- label: 'Opportunities',
- component: 'related_list',
- object: 'opportunity',
- filter: { customer: '$recordId' },
- },
- ],
- },
- ],
+// src/views/customer.view.ts
+import { defineView } from '@objectstack/spec';
+
+// The same defineView container also carries the form layout. A form is a
+// list of sections, each holding fields.
+export const CustomerView = defineView({
+ form: {
+ type: 'simple',
+ data: { provider: 'object', object: 'customer' },
+ sections: [
+ {
+ label: 'Company Information',
+ fields: ['name', 'industry', 'annual_revenue'],
+ },
+ {
+ label: 'Contact',
+ fields: ['primary_contact'],
+ },
+ ],
+ },
});
```
@@ -290,6 +272,13 @@ The UI doesn't "know" the field types. It asks ObjectQL for the schema and rende
Let's trace a **real-world scenario**: A sales rep creates a new high-value customer.
+
+The TypeScript in Steps 2–6 below is **conceptual pseudo-code** illustrating the
+flow of control between layers. Calls like `Auth.getCurrentUser()`,
+`Permission.check()`, `ObjectQL.getSchema()`, and `Workflow.getTriggersFor()` are
+not real exported APIs — they stand in for the kernel's internal orchestration.
+
+
### Step 1: User Action (ObjectUI)
```
@@ -431,15 +420,23 @@ export const Opportunity = ObjectSchema.create({
### 2. ObjectOS: Define Business Rules
```typescript
-export const OpportunityWorkflow = Workflow({
- object: 'opportunity',
- trigger: 'field_update',
- conditions: [
- { field: 'stage', operator: 'equals', value: 'closed_won' },
+import { defineFlow } from '@objectstack/spec';
+
+export const OpportunityWonFlow = defineFlow({
+ name: 'opportunity_won',
+ label: 'Opportunity Closed Won',
+ type: 'record_change',
+ status: 'active',
+ nodes: [
+ { id: 'start', type: 'start', label: 'Stage changed' },
+ { id: 'invoice', type: 'create_record', label: 'Create invoice' },
+ { id: 'notify', type: 'send_email', label: 'Notify sales team' },
+ { id: 'end', type: 'end', label: 'End' },
],
- actions: [
- { type: 'create_invoice', params: { object: 'invoice' } },
- { type: 'send_notification', params: { to: 'sales_team' } },
+ edges: [
+ { id: 'e1', source: 'start', target: 'invoice', condition: "record.stage == 'closed_won'" },
+ { id: 'e2', source: 'invoice', target: 'notify' },
+ { id: 'e3', source: 'notify', target: 'end' },
],
});
```
@@ -447,16 +444,23 @@ export const OpportunityWorkflow = Workflow({
### 3. ObjectUI: Define the Kanban View
```typescript
-export const OpportunityKanban = ListView({
- object: 'opportunity',
- type: 'kanban',
- groupBy: 'stage',
- columns: [
- { field: 'title' },
- { field: 'amount' },
- { field: 'customer' },
- ],
- enableDragDrop: true,
+import { defineView } from '@objectstack/spec';
+
+export const OpportunityKanbanView = defineView({
+ list: {
+ type: 'kanban',
+ data: { provider: 'object', object: 'opportunity' },
+ columns: [
+ { field: 'title' },
+ { field: 'amount' },
+ { field: 'customer' },
+ ],
+ // Kanban-specific config: group columns by the stage field.
+ kanban: {
+ groupByField: 'stage',
+ summarizeField: 'amount',
+ },
+ },
});
```
diff --git a/content/docs/getting-started/cli.mdx b/content/docs/getting-started/cli.mdx
index cabdebdfef..b269c1e174 100644
--- a/content/docs/getting-started/cli.mdx
+++ b/content/docs/getting-started/cli.mdx
@@ -86,7 +86,8 @@ os init my-app --no-install # Skip dependency installation
**Options:**
- `-t, --template ` — Template: `app` (default), `plugin`, `empty`
-- `--no-install` — Skip automatic `pnpm install`
+- `--no-install` — Skip automatic dependency installation
+- `-p, --package-manager ` — Package manager to use (auto-detected from the environment)
**Templates:**
@@ -173,8 +174,8 @@ only gate the **automatic** registration of optional plugins.
| Preset | Tiers | Auto-loaded optional plugins |
|:---|:---|:---|
| `minimal` | `core` | none |
-| `default` *(default)* | `core`, `i18n`, `ui`, `auth` | i18n service, Studio UI, Auth + Security + Audit |
-| `full` | `core`, `i18n`, `ui`, `ai`, `auth` | adds the AI service tier |
+| `default` *(default)* | `core`, `i18n`, `ui`, `ai`, `auth` | i18n service, Studio 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`
is skipped with a yellow warning and the `/api/v1/auth/*` endpoints will
@@ -230,7 +231,7 @@ Boots a production server **directly from a compiled `objectstack.json` artifact
command: hand a server one JSON file (or a URL pointing at one) and it runs.
```bash
-# Quick start — load ./dist/objectstack.json with sqlite at .objectstack/data/standalone.db
+# Quick start — load ./dist/objectstack.json with sqlite at file:/data/objectstack.db
os start
# Pick everything via flags (no env vars needed)
@@ -267,7 +268,7 @@ os start
| `--auth-secret ` | `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. |
-| `--ui` | — | Mount the Console portal at `/_console/`. `os dev` mounts it by default; `os start` can mount it for production administration when explicitly enabled. |
+| `--ui` / `--no-ui` | — | Mount the Console portal at `/_console/`. Enabled by default (so you can install marketplace apps); pass `--no-ui` to disable it. |
> **Port conflicts: production never auto-shifts.** Unlike `os dev` (which
> hops to the next free port for local convenience), `os start` exits with an
@@ -278,7 +279,7 @@ os start
| `-v, --verbose` | — | Verbose output |
**Resolution priority (artifact):** `--artifact` > `OS_ARTIFACT_PATH` > `/dist/objectstack.json`.
-**Resolution priority (database):** `--database` > `OS_DATABASE_URL` > `TURSO_DATABASE_URL` > `file:.objectstack/data/standalone.db`.
+**Resolution priority (database):** `--database` > `OS_DATABASE_URL` > `DATABASE_URL` (legacy) > `file:/data/objectstack.db`.
**What it boots:**
- Reads the artifact's `manifest`, `objects`, `views`, `flows`, …
@@ -360,7 +361,7 @@ os validate path/to/config # Validate specific file
**Warnings checked:**
- Missing `manifest.id` (required for deployment)
-- Invalid `manifest.scope` (must be `cloud`, `system`, or `project`)
+- Missing `manifest.namespace` (required for multi-app hosting)
- No objects defined
- No apps or plugins defined
@@ -488,50 +489,50 @@ os doctor -v # Show fix suggestions for warnings
| Command | Description |
|---------|-------------|
-| `os auth register` | Create an account and store local credentials |
-| `os auth login` | Sign in and store credentials in `~/.objectstack/credentials.json` |
-| `os auth whoami` | Show the current authenticated user |
-| `os auth logout` | Revoke the server session and clear local credentials |
+| `os register` | Create an account and store local credentials |
+| `os login` | Sign in and store credentials in `~/.objectstack/credentials.json` |
+| `os whoami` | Show the current authenticated user |
+| `os logout` | Revoke the server session and clear local credentials |
-#### `os auth register`
+#### `os register`
Creates a user account and stores the returned token locally.
```bash
-os auth register
-os auth register --email user@example.com --name "Jane Doe" --password secret
-os auth register --url https://api.example.com
+os register
+os register --email user@example.com --name "Jane Doe" --password secret
+os register --url https://api.example.com
```
-#### `os auth login`
+#### `os login`
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.
```bash
-os auth login
-os auth login --url https://api.example.com
-os auth login --no-browser
+os login
+os login --url https://api.example.com
+os login --no-browser
```
-If a valid token already exists, `os auth login` exits successfully with
-"Already logged in as ``". Use `os auth logout` to switch users, or pass
+If a valid token already exists, `os login` exits successfully with
+"Already logged in as ``". Use `os logout` to switch users, or pass
`--force` to re-authenticate.
For CI and other non-interactive contexts, pass email/password directly:
```bash
-os auth login --email user@example.com --password secret
+os login --email user@example.com --password secret
```
-#### `os auth logout`
+#### `os logout`
Logout calls `POST /api/v1/auth/sign-out` before deleting local credentials, so
the server-side session is revoked as well.
```bash
-os auth logout
+os logout
```
### Cloud Environments
diff --git a/content/docs/getting-started/core-concepts.mdx b/content/docs/getting-started/core-concepts.mdx
index 4a4072f1b8..486135f776 100644
--- a/content/docs/getting-started/core-concepts.mdx
+++ b/content/docs/getting-started/core-concepts.mdx
@@ -132,9 +132,13 @@ This ensures ObjectStack apps can run on Node.js + PostgreSQL today, Python + SQ
| Layer | Responsibility | Example |
| :--- | :--- | :--- |
-| **Protocol** | Defines capabilities | `allowRead: string` (a slot for a formula) |
-| **App** | Defines business logic | `allowRead: "$user.role == 'admin'"` |
-| **Engine** | Enforces the logic | Compiles formula to SQL `WHERE` clause |
+| **Protocol** | Defines capabilities | A row-level-security policy slot (`operation` + `using` clause) |
+| **App** | Defines business logic | `{ operation: 'select', using: "role = 'admin'" }` |
+| **Engine** | Enforces the logic | Compiles the `using` condition into a SQL `WHERE` clause |
+
+
+CRUD permissions (`allowRead`, `allowEdit`, …) are simple booleans — they grant or deny an operation. Record-level *conditional* access is a separate mechanism: [Row-Level Security](/docs/getting-started/architecture) policies, whose `using` clause is a SQL-like (PostgreSQL-compatible) predicate over context variables such as `current_user.role` and `current_user.id`.
+
### Single Source of Truth
@@ -153,7 +157,7 @@ AI agents should not bypass the application model by calling raw SQL, scraping U
```
Traditional AI app: Database schema -> App code -> Custom query -> Hand-written MCP tool
-ObjectStack: Zod metadata -> Environment Artifact -> ObjectStack runtime -> API / UI / MCP tools
+ObjectStack: Zod metadata -> Metadata registry -> ObjectStack runtime -> API / UI / MCP tools
```
---
diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx
index 9689a83c06..af2625f9ee 100644
--- a/content/docs/getting-started/examples.mdx
+++ b/content/docs/getting-started/examples.mdx
@@ -7,7 +7,7 @@ import { CheckSquare, Building2, BarChart3, Server } from 'lucide-react';
# Example Apps
-The monorepo includes 2 ready-to-run examples that progressively demonstrate ObjectStack features — from a simple Todo app to a reusable BI plugin template. For a full enterprise reference, see the [HotCRM repository](https://github.com/objectstack-ai/hotcrm).
+The monorepo ships three ready-to-run examples in `examples/` that progressively demonstrate ObjectStack features — from a simple Todo app to a full CRM and a kitchen-sink reference. For a larger external enterprise reference, see the [HotCRM repository](https://github.com/objectstack-ai/hotcrm).
}
- title="HotCRM (external)"
- href="https://github.com/objectstack-ai/hotcrm"
- description="Full-featured CRM: 10+ objects, AI agents, flows, security, sharing rules."
+ title="app-crm"
+ description="A full CRM example (in-repo): multiple objects, views, apps, and seed data."
/>
}
- title="plugin-bi"
- description="A reusable plugin template. Shows how to package metadata for distribution."
+ title="app-showcase"
+ description="The kitchen-sink reference. Exercises most metadata types in one app."
/>
@@ -41,13 +40,13 @@ The monorepo includes 2 ready-to-run examples that progressively demonstrate Obj
The fastest way to explore all examples at once:
```bash
-git clone https://github.com/nickstenning/spec.git
-cd spec
+git clone https://github.com/objectstack-ai/framework.git
+cd framework
pnpm install
-pnpm studio
+pnpm dev:showcase # or: pnpm dev:todo / pnpm dev:crm
```
-Open [http://localhost:3000/_studio/](http://localhost:3000/_studio/) — the root `objectstack.config.ts` aggregates Todo + CRM + BI into one dev workspace.
+Each script starts one example's dev server. `pnpm dev` is an alias for `pnpm dev:showcase`.
---
@@ -64,17 +63,22 @@ examples/app-todo/
├── objectstack.config.ts # App manifest + metadata wiring
├── src/
│ ├── objects/
-│ │ └── task.object.ts # Task object: 10 fields, validations, indexes
+│ │ └── task.object.ts # todo_task object: fields, validations, indexes
│ ├── actions/
│ │ └── task.actions.ts # Complete, Start, Defer, Delete batch
│ ├── apps/
│ │ └── todo.app.ts # Navigation: Tasks, Analytics
+│ ├── views/ # List / form views
│ ├── dashboards/
│ │ └── task.dashboard.ts # Widget grid: stats, charts, lists
│ ├── reports/
│ │ └── task.report.ts # Tabular + summary reports
-│ └── flows/
-│ └── task.flow.ts # Auto-assignment automation
+│ ├── datasets/ # Saved query datasets for charts
+│ ├── flows/
+│ │ └── task.flow.ts # Auto-assignment automation
+│ ├── translations/ # Locale bundles
+│ └── data/
+│ └── index.ts # Seed data via defineSeed()
```
### Key Concepts Demonstrated
@@ -82,7 +86,7 @@ examples/app-todo/
| Concept | File | What You'll Learn |
|:--------|:-----|:------------------|
| Object & Fields | `task.object.ts` | `Field.text()`, `Field.select()`, field options, indexes |
-| Seed Data | `objectstack.config.ts` | `data` array with upsert mode, 8 sample records |
+| Seed Data | `src/data/index.ts` | `defineSeed()` with upsert mode, 8 sample records |
| Actions | `task.actions.ts` | Script actions, modal actions with params |
| App Navigation | `todo.app.ts` | Navigation groups, object links, dashboard links |
| Automation | `task.flow.ts` | Autolaunched flow with record-triggered logic |
@@ -98,8 +102,8 @@ pnpm dev
```bash
-# Already included in root objectstack.config.ts
-pnpm studio
+# From the monorepo root
+pnpm dev:todo
```
@@ -112,7 +116,7 @@ pnpm studio
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const Task = ObjectSchema.create({
- name: 'task',
+ name: 'todo_task',
label: 'Task',
pluralLabel: 'Tasks',
icon: 'check-square',
@@ -123,27 +127,26 @@ export const Task = ObjectSchema.create({
required: true,
searchable: true,
}),
- status: {
- type: 'select',
+ status: Field.select({
label: 'Status',
required: true,
options: [
- { label: 'Not Started', value: 'not_started', default: true },
- { label: 'In Progress', value: 'in_progress' },
- { label: 'Completed', value: 'completed' },
+ { label: 'Not Started', value: 'not_started', color: '#808080', default: true },
+ { label: 'In Progress', value: 'in_progress', color: '#3B82F6' },
+ { label: 'Completed', value: 'completed', color: '#10B981' },
],
- },
+ }),
due_date: Field.date({ label: 'Due Date' }),
- priority: {
- type: 'select',
+ priority: Field.select({
label: 'Priority',
+ required: true,
options: [
- { label: 'Low', value: 'low' },
- { label: 'Normal', value: 'normal' },
- { label: 'High', value: 'high' },
- { label: 'Urgent', value: 'urgent' },
+ { label: 'Low', value: 'low', color: '#60A5FA', default: true },
+ { label: 'Normal', value: 'normal', color: '#10B981' },
+ { label: 'High', value: 'high', color: '#F59E0B' },
+ { label: 'Urgent', value: 'urgent', color: '#EF4444' },
],
- },
+ }),
},
});
```
@@ -155,6 +158,7 @@ import { defineStack } from '@objectstack/spec';
import * as objects from './src/objects';
import * as actions from './src/actions';
import * as apps from './src/apps';
+import { TodoSeedData } from './src/data';
export default defineStack({
manifest: {
@@ -169,20 +173,31 @@ export default defineStack({
actions: Object.values(actions),
apps: Object.values(apps),
- // Seed data loaded at startup
- data: [{
- object: 'task',
- mode: 'upsert',
- externalId: 'subject',
- records: [
- { subject: 'Learn ObjectStack', status: 'completed', priority: 'high' },
- { subject: 'Build a cool app', status: 'in_progress', priority: 'normal' },
- ],
- }],
+ // Seed data authored with defineSeed() in src/data/
+ data: TodoSeedData,
+});
+```
+
+Seed data is authored separately with `defineSeed()` and wired in via the `data`
+key. For example, `src/data/index.ts`:
+
+```typescript
+import { defineSeed } from '@objectstack/spec/data';
+import { Task } from '../objects/task.object';
+
+const tasks = defineSeed(Task, {
+ mode: 'upsert',
+ externalId: 'subject',
+ records: [
+ { subject: 'Learn ObjectStack', status: 'completed', priority: 'high' },
+ { subject: 'Build a cool app', status: 'in_progress', priority: 'normal' },
+ ],
});
+
+export const TodoSeedData = [tasks];
```
-**3. The server auto-detects** what you need: ObjectQL engine → InMemory driver → Hono HTTP server → REST API at `/api/v1/task`.
+**3. The server auto-detects** what you need: ObjectQL engine → InMemory driver → Hono HTTP server → REST API at `/api/v1/todo_task`.
---
@@ -245,36 +260,22 @@ os compile # Build to dist/objectstack.json
---
-## plugin-bi — Reusable Plugin
+## app-showcase — Kitchen-Sink Reference
-**Path:** `examples/plugin-bi/`
+**Path:** `examples/app-showcase/`
-A minimal plugin template showing how to package metadata for distribution. Plugins use `type: 'plugin'` in their manifest and can be composed into any host app.
-
-```typescript
-// examples/plugin-bi/objectstack.config.ts
-import { defineStack } from '@objectstack/spec';
+A kitchen-sink workspace built for demonstration and debugging. It exercises
+nearly every metadata type, view type, and chart type in a single app — objects,
+views, apps, pages, dashboards, reports, datasets, flows, jobs, agents, security
+profiles, translations, themes, webhooks, and more. Use it as a living reference
+when you want to see how a particular metadata type is authored.
-export default defineStack({
- manifest: {
- id: 'com.example.bi',
- namespace: 'bi',
- version: '1.0.0',
- type: 'plugin', // ← Plugin, not app
- name: 'BI Plugin',
- description: 'Business Intelligence dashboards and analytics',
- },
- objects: [],
- dashboards: [],
-});
+```bash
+cd examples/app-showcase
+pnpm dev
```
-**Key difference from apps:** Plugins don't run standalone — they're loaded by a host app via `AppPlugin`:
-
-```typescript
-import BiPlugin from '../plugin-bi/objectstack.config';
-new AppPlugin(BiPlugin) // Load into host
-```
+It is also the target of `pnpm dev` / `pnpm dev:showcase` from the monorepo root.
---
@@ -284,23 +285,15 @@ Compose apps and plugins in `objectstack.config.ts`, compile them into one
artifact, then boot that artifact with the CLI.
```typescript
-import { defineStack } from '@objectstack/spec';
-import CrmApp from 'hotcrm/objectstack.config'; // from https://github.com/objectstack-ai/hotcrm
+import { composeStacks } from '@objectstack/spec';
+import CrmApp from '../examples/app-crm/objectstack.config';
import TodoApp from '../examples/app-todo/objectstack.config';
-import BiPlugin from '../examples/plugin-bi/objectstack.config';
-export default defineStack({
- manifest: {
- id: 'com.objectstack.server',
- version: '1.0.0',
- type: 'app',
- },
- imports: [
- CrmApp,
- TodoApp,
- BiPlugin,
- ],
-});
+// composeStacks merges objects, apps, and other metadata from each stack.
+// The combined manifest is selected per the `manifest` option; the first
+// stack's manifest is used by default. Duplicate object names throw unless
+// you pass an `objectConflict` strategy ('override' | 'merge').
+export default composeStacks([CrmApp, TodoApp]);
```
### Short Names Are Canonical
@@ -309,11 +302,10 @@ Each app declares a `namespace` in its manifest, but **the short object name is
| App | Namespace | Object name | Physical table |
|:----|:----------|:------------|:---------------|
-| Todo | `todo` | `task` | `task` |
-| CRM | `crm` | `account` | `account` |
-| BI | `bi` | `report` | `report` |
+| Todo | `todo` | `todo_task` | `todo_task` |
+| CRM | `crm` | `crm_account` | `crm_account` |
-If two packages contribute objects with the same short name, the registry logs a warning. Resolve the collision by renaming one object's short `name` (for example, `crm_account`) rather than using FQN strings in user code.
+If two packages contribute objects with the same short name, the registry logs a warning. Resolve the collision by giving one object a package-prefixed short `name` (the convention the examples follow, e.g. `crm_account`, `todo_task`) rather than using FQN strings in user code.
### Run It
@@ -326,7 +318,8 @@ OS_ARTIFACT_PATH=./dist/objectstack.json os start
## Project Structure Conventions
-All examples follow the same pattern. When you run `os init`, you get this structure:
+All examples follow the same pattern. The recommended project layout — used by
+every example in `examples/` — looks like this:
```
my-app/
@@ -343,14 +336,21 @@ my-app/
│ ├── dashboards/ # Analytics dashboards (optional)
│ ├── reports/ # Report definitions (optional)
│ ├── agents/ # AI agents (optional)
-│ └── rag/ # RAG pipelines (optional)
+│ └── data/ # Seed data via defineSeed() (optional)
└── test/ # Tests
```
+
+ `os init` scaffolds a minimal starter, not this full tree. It emits
+ `objectstack.config.ts` plus a single object under
+ `src/objects/{namespace}_item.ts` (e.g. `my_app_item.ts`) with a matching
+ barrel `src/objects/index.ts`. Add the other directories above as your app grows.
+
+
**Naming conventions:**
- **Directories:** plural (`objects/`, `actions/`, `flows/`)
- **Files:** `{name}.object.ts`, `{name}.actions.ts`, `{name}.flow.ts`
-- **Object names:** `snake_case` short names (`task`, `sales_order`, `account`)
+- **Object names:** `snake_case` short names with a package prefix (`todo_task`, `crm_account`)
- **Config keys:** `camelCase` (`maxLength`, `defaultValue`, `pluralLabel`)
## What's Next
diff --git a/content/docs/getting-started/glossary.mdx b/content/docs/getting-started/glossary.mdx
index effb7fa978..dd5ccd8164 100644
--- a/content/docs/getting-started/glossary.mdx
+++ b/content/docs/getting-started/glossary.mdx
@@ -10,7 +10,7 @@ To navigate the ObjectStack ecosystem effectively, it is helpful to understand t
## The Ecosystem
### ObjectStack
-The umbrella term for the entire suite of protocols and reference implementations. It is organized into **15 protocol namespaces** grouped into three architectural layers.
+The umbrella term for the entire suite of protocols and reference implementations. It is organized into **15 protocol namespaces** (enumerated below).
### Protocol Namespace
A logical grouping of related schemas and types defined with Zod. ObjectStack has 15 protocol namespaces: Data, UI, System, Automation, AI, API, Identity, Security, Kernel, Cloud, QA, Contracts, Integration, Studio, and Shared.
@@ -94,7 +94,8 @@ The fundamental unit of data modeling in ObjectStack. Roughly equivalent to a "T
### Driver
An adapter plugin in the Data Protocol runtime stack that allows the Data Layer to communicate with a specific underlying storage engine.
-* *Example:* `@objectstack/driver-sql` (Postgres / MySQL / SQLite via Knex), `@objectstack/driver-mongodb`, `@objectstack/driver-turso`.
+* *Example (this repo):* `@objectstack/driver-sql` (Postgres / MySQL / SQLite via Knex), `@objectstack/driver-mongodb`, `@objectstack/driver-memory` (in-memory, for testing), `@objectstack/driver-sqlite-wasm`.
+* *Cloud distribution:* `@objectstack/driver-turso` (edge-first SQLite) ships separately, not in this repository.
### AST (Abstract Syntax Tree)
The intermediate representation of a query or schema. The Data Protocol parses a JSON request into an AST before the Driver translates it into SQL/NoSQL queries. This allows for security validation and optimization before execution.
diff --git a/content/docs/getting-started/index.mdx b/content/docs/getting-started/index.mdx
index e098e3c98e..b91a408cbd 100644
--- a/content/docs/getting-started/index.mdx
+++ b/content/docs/getting-started/index.mdx
@@ -47,7 +47,7 @@ Think of ObjectStack as:
- **Kubernetes** for business applications - Declarative configuration over imperative code
- **Terraform** for data modeling - Infrastructure as code, but for data
-- **GraphQL + React Server Components** - Schema-driven data + UI rendering combined
+- **GraphQL + React Server Components** - Schema-driven data + UI rendering combined (REST ships today; GraphQL is exposed via the `IGraphQLService` contract)
- **MCP for business systems** - Structured, permission-aware tools generated from metadata
## Key Features
@@ -81,7 +81,7 @@ Think of ObjectStack as:
| Traditional Approach | ObjectStack Approach |
| :--- | :--- |
| Write SQL migrations manually | Schema changes sync automatically |
-| Build CRUD APIs by hand | REST/GraphQL generated from schema |
+| Build CRUD APIs by hand | REST generated from schema (GraphQL via the `IGraphQLService` contract) |
| Manually define agent tools | MCP/tool surfaces generated from metadata |
| Duplicate validation logic 3x | Define once, enforce everywhere |
| Lock into one database vendor | Swap databases without code changes |
@@ -108,11 +108,11 @@ Before you start, make sure you have the following installed:
| Tool | Minimum Version | Check Command |
|:---|:---|:---|
| **Node.js** | 18.0.0+ | `node --version` |
-| **pnpm** | 8.0.0+ | `pnpm --version` |
-| **TypeScript** | 5.3.0+ | `npx tsc --version` |
+| **pnpm** | 8.0.0+ (repo pins 10.x) | `pnpm --version` |
+| **TypeScript** | 5.3.0+ (project targets 6.x) | `npx tsc --version` |
-**Why pnpm?** ObjectStack uses pnpm workspaces for monorepo management. Install it with `npm install -g pnpm` or `corepack enable`.
+**Why pnpm?** ObjectStack uses pnpm workspaces for monorepo management. Install it with `npm install -g pnpm` or `corepack enable` (corepack will pull the pinned pnpm 10.x from the repo's `packageManager` field).
## Common Setup Issues
@@ -126,17 +126,18 @@ corepack enable
```
### TypeScript version mismatch
-ObjectStack requires TypeScript 5.3+. Update with:
+ObjectStack works with TypeScript 5.3+, but the project itself is built and tested against TypeScript 6.x. Update with:
```bash
-pnpm add -D typescript@latest
+pnpm add -D typescript@^6
```
### Port 3000 already in use
+`objectstack dev` (what `pnpm dev` runs) automatically shifts to the next free port when 3000 is busy, so you usually don't need to do anything. To pin an explicit port instead:
```bash
# Find the process using port 3000
lsof -i :3000
-# Kill it or use a different port
-PORT=3001 pnpm dev
+# Use an explicit port (OS_PORT; PORT is the legacy alias)
+OS_PORT=3001 pnpm dev
```
For more troubleshooting, see the [Troubleshooting & FAQ](/docs/guides/troubleshooting) guide.
diff --git a/content/docs/getting-started/quick-start.mdx b/content/docs/getting-started/quick-start.mdx
index 4b20f4d029..eef9824a3b 100644
--- a/content/docs/getting-started/quick-start.mdx
+++ b/content/docs/getting-started/quick-start.mdx
@@ -38,19 +38,26 @@ Scaffolding with `npm create objectstack@latest` runs this step automatically.
### Define your first object
-The scaffolded project includes a sample object. Open `src/objects/my_app.ts`:
+The scaffolded project includes a sample object. Open `src/objects/my_app_item.ts`:
```typescript
-import { ObjectSchema, Field } from '@objectstack/spec/data';
+import * as Data from '@objectstack/spec/data';
-const myApp = ObjectSchema.create({
- name: 'my_app',
- label: 'My App',
- ownership: 'own',
+const myAppItem: Data.Object = {
+ name: 'my_app_item',
+ label: 'My App Item',
fields: {
- name: Field.text({ label: 'Name', required: true }),
- description: Field.textarea({ label: 'Description' }),
- status: Field.select({
+ name: {
+ type: 'text',
+ label: 'Name',
+ required: true,
+ },
+ description: {
+ type: 'textarea',
+ label: 'Description',
+ },
+ status: {
+ type: 'select',
label: 'Status',
options: [
{ label: 'Draft', value: 'draft' },
@@ -58,11 +65,11 @@ const myApp = ObjectSchema.create({
{ label: 'Archived', value: 'archived' },
],
defaultValue: 'draft',
- }),
+ },
},
-});
+};
-export default myApp;
+export default myAppItem;
```
@@ -72,15 +79,15 @@ This is the same metadata that powers the REST API, Studio editors, and the MCP
### Launch the Studio
```bash
-os studio
+os dev --ui
```
-Open [http://localhost:3000/_studio/](http://localhost:3000/_studio/) to browse your objects, test the REST API, and inspect metadata. The server automatically provides:
+Open [http://localhost:3000/_console/](http://localhost:3000/_console/) to browse your objects, test the REST API, and inspect metadata. The server automatically provides:
- **ObjectQL Engine** — Query layer with CRUD operations
- **InMemory Driver** — Zero-config data storage for development
-- **Hono HTTP Server** — REST API at `/api/v1/{object}`
-- **Console UI** — Admin interface at `/_studio/`
+- **Hono HTTP Server** — REST API at `/api/v1/data/{object}`
+- **Console UI** — Admin interface at `/_console/`
### Add more metadata
@@ -147,7 +154,7 @@ my-app/
├── src/
│ ├── objects/ # Data models (required)
│ │ ├── index.ts # Barrel export
-│ │ └── my_app.ts # Object definition
+│ │ └── my_app_item.ts # Object definition
│ ├── actions/ # Buttons, batch operations
│ ├── flows/ # Automation logic
│ ├── apps/ # Navigation definitions
@@ -157,11 +164,11 @@ my-app/
└── test/ # Tests
```
-**File naming conventions:**
+**File naming conventions** (recommended, not enforced — `os g ` always emits `{name}.ts`, so rename as you prefer):
- **Object files:** `{name}.object.ts` or `{name}.ts` (one object per file)
- **Action files:** `{name}.actions.ts`
- **Flow files:** `{name}.flow.ts`
-- **Barrel exports:** Each directory has an `index.ts` that re-exports everything
+- **Barrel exports:** Each directory has an `index.ts` that re-exports everything. Metadata is wired into your app through these barrel imports in `objectstack.config.ts` — there is no filename-suffix discovery.
## Key Concepts
diff --git a/content/docs/guides/adding-a-metadata-type.mdx b/content/docs/guides/adding-a-metadata-type.mdx
index 02ed84a55d..0bec85d064 100644
--- a/content/docs/guides/adding-a-metadata-type.mdx
+++ b/content/docs/guides/adding-a-metadata-type.mdx
@@ -1,11 +1,11 @@
---
title: Adding a Metadata Type
-description: How to register a new metadata type so it shows up in the Setup app's Metadata Admin with full CRUD, overlay diffing, and (optionally) a custom editor.
+description: How to register a new metadata type so it shows up in the Studio app's Metadata Admin with full CRUD, overlay diffing, and (optionally) a custom editor.
---
# Adding a Metadata Type
-The **Metadata Admin** engine (Setup app → *All Metadata Types*) automatically
+The **Metadata Admin** engine (Studio app → *All Metadata Types*) automatically
renders a directory tile, list page, schema-driven form, layered diff view,
quick-find palette, and version history for **every** type registered in the
metadata plugin registry. To plug in a new type you usually only need two
@@ -14,50 +14,69 @@ files; a third file is required only if you want a bespoke editor.
## TL;DR
```
-1. Add an entry to DEFAULT_METADATA_TYPE_REGISTRY (packages/spec)
+1. Register the type entry: built-in -> DEFAULT_METADATA_TYPE_REGISTRY;
+ plugin -> additionalTypes on MetadataPluginConfig (packages/spec)
2. Define a Zod schema for the type (packages/spec/src//)
3. (Optional) Register a custom editor (objectui/.../builtinComponents.tsx)
```
-That's it — no UI code required for the 80% case. The engine introspects the
-registry entry to pick an icon, a domain group, whether to allow runtime
-overrides, and the JSON schema used to generate the form.
+That's it — no UI code required for the 80% case. The engine reads the
+registry entry to pick a domain group and whether to allow runtime
+overrides, and resolves the type's Zod schema (via `getMetadataTypeSchema`)
+to generate the form.
---
## 1. Register the type
-The single source of truth is the `DEFAULT_METADATA_TYPE_REGISTRY` in
-`packages/spec/src/kernel/metadata-plugin.zod.ts`. Each entry follows
-`MetadataTypeEntrySchema`:
+The single source of truth for built-in types is the
+`DEFAULT_METADATA_TYPE_REGISTRY` in
+`packages/spec/src/kernel/metadata-plugin.zod.ts`. Each entry is validated by
+`MetadataTypeRegistryEntrySchema`:
```ts
{
type: 'my_widget', // canonical metadata type name (singular, snake_case)
label: 'My Widget', // display label (English)
- domain: 'ui', // one of: data | ui | automation | ai | api | identity | security | system | platform | other
- icon: 'Box', // lucide-react icon name
description: 'Widgets shown on the home dashboard.',
- schema: MyWidgetJsonSchema, // JSON Schema generated from your Zod (see step 2)
- allowOrgOverride: true, // false = compile-time only, true = runtime overlays allowed
- allowRuntimeCreate: true, // allow admin to create new instances at runtime
+ domain: 'ui', // one of: data | ui | automation | system | security | ai
+ filePatterns: ['**/*.my-widget.ts', '**/*.my-widget.yml'], // required: globs used to discover files of this type
supportsOverlay: true, // false = no 3-state diff in the editor
+ allowOrgOverride: true, // false = compile-time only, true = runtime overlays accepted via the metadata API
+ allowRuntimeCreate: true, // allow admin to create new instances at runtime
}
```
+> The registry entry has **no** `icon` or `schema` field — the form is
+> generated from the type's Zod schema (step 2), not from anything stored on
+> the entry. `filePatterns` is **required**; omitting it fails validation.
+
> **Naming rule (Prime Directive #3):** the `type` field is **singular**
-> (`widget`, not `widgets`). REST endpoints below add the plural automatically
-> (`/api/v1/meta/widgets`).
+> (`my_widget`, not `my_widgets`). REST routes use the canonical type name
+> verbatim as the `:type` path param (`/api/v1/meta/my_widget`) — there is no
+> automatic pluralization.
The `allowOrgOverride` flag is the **only** place that controls whether the
overlay store accepts writes for this type. The runtime env-var
-`OBJECTSTACK_METADATA_WRITABLE=foo,bar` flips the flag on at boot for the
-listed types — useful for opt-in writable behaviour in production.
+`OS_METADATA_WRITABLE=foo,bar` (legacy alias: `OBJECTSTACK_METADATA_WRITABLE`)
+flips the flag on at runtime for the listed types — useful for opt-in writable
+behaviour in production. The allow-list is parsed and cached lazily on first
+use, not pinned at process start.
+
+### Built-in vs plugin-contributed types
+
+`DEFAULT_METADATA_TYPE_REGISTRY` is the core built-in array — edit it (and
+`BUILTIN_METADATA_TYPE_SCHEMAS`, step 2) only for types that ship with the
+platform. A third-party package contributes its own types instead through
+the **`additionalTypes`** array on `MetadataPluginConfig`, and registers the
+matching Zod schema with `registerMetadataTypeSchema(type, schema)` from its
+`onInstall` hook so the `/api/v1/meta/types/:type` endpoint emits a real JSON
+Schema. The registry entry shape is the same in both cases.
## 2. Define the Zod schema
-Place the schema under the appropriate domain folder, mirroring the
-namespace it belongs to (e.g. `packages/spec/src/ui/my-widget.zod.ts`):
+Place the schema under the matching domain folder
+(e.g. `packages/spec/src/ui/my-widget.zod.ts`):
```ts
import { z } from 'zod';
@@ -76,14 +95,31 @@ export const MyWidgetSchema = z.object({
export type MyWidget = z.infer;
```
-Then export the JSON Schema for the registry:
+Then wire the Zod schema so the engine can resolve it for this type. The
+registry entry does **not** carry a JSON Schema — instead the engine looks the
+type up via `getMetadataTypeSchema(type)` and derives the JSON Schema the
+editor consumes from the registered Zod schema.
-```ts
-import { zodToJsonSchema } from 'zod-to-json-schema';
-export const MyWidgetJsonSchema = zodToJsonSchema(MyWidgetSchema, { name: 'MyWidget' });
-```
+- **Built-in types:** add the entry to `BUILTIN_METADATA_TYPE_SCHEMAS` in
+ `packages/spec/src/kernel/metadata-type-schemas.ts`:
+
+ ```ts
+ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = {
+ // …
+ my_widget: MyWidgetSchema,
+ };
+ ```
+
+- **Plugin-contributed types:** call `registerMetadataTypeSchema` from your
+ plugin's `onInstall` hook (see *Plugin lifecycle*, below):
+
+ ```ts
+ import { registerMetadataTypeSchema } from '@objectstack/spec';
+ registerMetadataTypeSchema('my_widget', MyWidgetSchema);
+ ```
-The Metadata Admin **SchemaForm** consumes this JSON Schema and produces:
+The Metadata Admin **SchemaForm** consumes the JSON Schema derived from this
+Zod schema and produces:
- A field per top-level property
- Inline validation matching the Zod constraints (min/max/regex/enum)
@@ -137,18 +173,35 @@ Common designer prop shapes already supported:
## Routing
-No routing changes are required. The Setup app already includes:
+No routing changes are required. The Studio app already includes the
+directory entry in its navigation:
```ts
-// setup.app.ts
-nav: [
- { type: 'component', componentRef: 'metadata:directory', label: 'Data Model' },
+// studio.app.ts
+navigation: [
+ {
+ id: 'group_overview',
+ type: 'group',
+ label: 'Overview',
+ children: [
+ {
+ id: 'nav_metadata_directory',
+ type: 'component',
+ label: 'All Metadata Types',
+ componentRef: 'metadata:directory',
+ icon: 'layers',
+ },
+ // …
+ ],
+ },
+ // …
]
```
The directory page enumerates every registered type. The
-`metadata:resource` route handles list/edit/create/history for whatever
-`?type=` is selected.
+`metadata:resource` component handles list/edit/create/history for whatever
+type is selected, addressed via `params: { type, package }` rather than a
+query string.
## i18n
@@ -164,15 +217,15 @@ If you omit them, the directory falls back to the registry `label`.
## Checklist
-- [ ] Registry entry added (`type`, `label`, `domain`, `icon`, `schema`, flags)
+- [ ] Registry entry added (`type`, `label`, `domain`, required `filePatterns`, flags) — for plugins, via `additionalTypes` on `MetadataPluginConfig`
- [ ] Zod schema authored under `packages/spec/src//.zod.ts`
-- [ ] JSON Schema exported and referenced from the registry entry
+- [ ] Zod schema wired up: built-in → `BUILTIN_METADATA_TYPE_SCHEMAS`; plugin → `registerMetadataTypeSchema()` in `onInstall`
- [ ] (Optional) Custom editor registered in `builtinComponents.tsx`
- [ ] (Optional) i18n labels added to `metadata-admin/i18n.ts`
- [ ] `pnpm test` passes (framework) and `pnpm --filter @object-ui/app-shell build` passes (objectui)
## Related
-- [`/api/v1/meta/types`](./api-reference.mdx) — the REST endpoint that powers the directory
+- [`GET /api/v1/meta`](./api-reference.mdx) — lists every registered metadata type; per-type items at `GET /api/v1/meta/:type`, history at `GET /api/v1/meta/:type/:name/history`
- [Object & Field design](./data-modeling.mdx) — the canonical example of a writable metadata type
- [Plugin lifecycle](./plugin-development.mdx) — how third-party packages contribute types
diff --git a/content/docs/guides/ai-capabilities.mdx b/content/docs/guides/ai-capabilities.mdx
index 47691d8ed8..dbef98d2b5 100644
--- a/content/docs/guides/ai-capabilities.mdx
+++ b/content/docs/guides/ai-capabilities.mdx
@@ -5,7 +5,7 @@ description: "Complete guide to leveraging AI agents, RAG pipelines, and intelli
# AI Capabilities Guide
-Complete guide to leveraging AI agents, RAG pipelines, and intelligent automation in ObjectStack.
+Complete guide to leveraging AI agents, knowledge retrieval, and intelligent automation in ObjectStack.
## Table of Contents
@@ -13,10 +13,8 @@ Complete guide to leveraging AI agents, RAG pipelines, and intelligent automatio
2. [AI Agents](#ai-agents)
3. [Actions as Tools (explicit opt-in)](#actions-as-tools-explicit-opt-in)
- [Human-In-The-Loop approval](#human-in-the-loop-approval)
-4. [RAG Pipelines](#rag-pipelines)
-5. [Natural Language Queries](#natural-language-queries)
-6. [Predictive Analytics](#predictive-analytics)
-7. [Best Practices](#best-practices)
+4. [Knowledge Protocol (RAG via adapter plugins)](#knowledge-protocol-rag-via-adapter-plugins)
+5. [Best Practices](#best-practices)
---
@@ -26,22 +24,17 @@ ObjectStack provides a comprehensive AI platform:
```
┌─────────────────────────────────────┐
-│ AI Orchestration │ ← Coordinate AI workflows
+│ AI Agents │ ← Persona + skills/tools
+│ Skills & Tools │ ← Actions/Flows exposed to the LLM
├─────────────────────────────────────┤
-│ AI Agents │ ← Autonomous actors
-│ - Assistants │
-│ - Workers │
-│ - Analysts │
-├─────────────────────────────────────┤
-│ RAG Pipelines │ ← Knowledge retrieval
-│ - Vector Search │
-│ - Semantic Chunking │
-│ - Reranking │
+│ Knowledge Protocol │ ← search_knowledge via adapters
+│ - memory / ragflow / custom │
+│ - Permission-aware retrieval │
├─────────────────────────────────────┤
│ Model Registry │ ← LLM management
-│ - OpenAI, Anthropic, etc. │
-│ - Model routing │
-│ - Cost tracking │
+│ - openai, azure_openai, │
+│ anthropic, local │
+│ - Token accounting / cost │
└─────────────────────────────────────┘
```
@@ -51,29 +44,45 @@ ObjectStack provides a comprehensive AI platform:
AI agents are autonomous actors that perform tasks on behalf of users.
-### Agent Types
-
-| Type | Description | Use Cases |
-|------|-------------|-----------|
-| **Assistant** | Interactive helpers | Chatbots, Q&A, recommendations |
-| **Worker** | Background processors | Data enrichment, automation |
-| **Analyst** | Data analysts | Insights, forecasting, reporting |
-| **Creator** | Content generators | Emails, documents, designs |
+### How agents are shaped
+
+An agent is plain metadata validated by `AgentSchema` and created with the
+`defineAgent()` factory. The key fields are:
+
+| Field | Meaning |
+|------|---------|
+| `role` | Free-text **persona** string (e.g. `"Senior Support Engineer"`) — not an enum |
+| `instructions` | System prompt / prime directives |
+| `model` | Provider + model config (`provider`: `openai` \| `azure_openai` \| `anthropic` \| `local`) |
+| `skills` | Skill names to attach (the primary Agent → Skill → Tool capability model) |
+| `tools` | Direct tool **references** `{ type, name, description }` — `type` is `action` \| `flow` \| `query` \| `vector_search`; `name` points at an existing Action/Flow/query |
+| `knowledge` | RAG access: `{ topics: string[], indexes: string[] }` |
+
+There is no `type` field and no fixed agent "type" taxonomy — the way an agent
+behaves comes from its persona, instructions, skills, and tools. Likewise there
+are no `triggers` or `schedule` fields on an agent; drive agents from
+[Flows/Workflows](./automation) or invoke them via the chat endpoint when you
+need event- or time-based behaviour.
+
+
+Agent tools are **references** to existing Actions, Flows, or queries — you do
+not define ad-hoc tool names with inline parameter schemas here. See
+[Actions as Tools](#actions-as-tools-explicit-opt-in) for how an Action becomes
+LLM-callable.
+
### Sales Assistant Agent
```typescript
-import type { Agent } from '@objectstack/spec/ai';
+import { defineAgent } from '@objectstack/spec/ai';
-export const SalesAssistantAgent: Agent = {
+export const SalesAssistantAgent = defineAgent({
name: 'sales_assistant',
label: 'Sales Assistant',
- description: 'AI agent to help sales reps with lead qualification',
-
- role: 'assistant',
-
+ role: 'Sales Development Assistant',
+
instructions: `You are a sales assistant AI.
-
+
Your responsibilities:
1. Qualify incoming leads (BANT criteria)
2. Suggest next best actions
@@ -88,82 +97,32 @@ Always be professional and data-driven.`,
temperature: 0.7,
maxTokens: 2000,
},
-
- // Tools the agent can use
+
+ // References to Actions/Flows exposed as tools (see "Actions as Tools").
tools: [
- {
- name: 'analyze_lead',
- description: 'Analyze a lead and provide qualification score',
- parameters: {
- lead_id: 'string',
- },
- },
- {
- name: 'suggest_next_action',
- description: 'Suggest next best action for an opportunity',
- parameters: {
- opportunity_id: 'string',
- },
- },
- {
- name: 'generate_email',
- description: 'Generate a personalized email template',
- parameters: {
- recipient_id: 'string',
- context: 'string',
- tone: 'string',
- },
- },
+ { type: 'flow', name: 'analyze_lead', description: 'Analyze a lead and provide a qualification score' },
+ { type: 'flow', name: 'suggest_next_action', description: 'Suggest the next best action for an opportunity' },
+ { type: 'action', name: 'generate_email', description: 'Generate a personalized email template' },
],
-
- // Knowledge sources
+
+ // RAG access: topics to recruit knowledge from + vector store indexes.
knowledge: {
- sources: [
- {
- type: 'object',
- objectName: 'lead',
- fields: ['*'],
- },
- {
- type: 'object',
- objectName: 'opportunity',
- fields: ['*'],
- },
- {
- type: 'document',
- path: '/knowledge/sales-playbook.md',
- },
- ],
+ topics: ['sales-playbook', 'leads', 'opportunities'],
+ indexes: ['sales_docs'],
},
-
- // When to trigger the agent
- triggers: [
- {
- type: 'object_create',
- objectName: 'lead',
- condition: 'rating = "hot"',
- },
- {
- type: 'object_update',
- objectName: 'opportunity',
- condition: 'ISCHANGED(stage)',
- },
- ],
-};
+});
```
### Customer Service Agent
```typescript
-export const ServiceAgent: Agent = {
+export const ServiceAgent = defineAgent({
name: 'service_agent',
label: 'Customer Service Agent',
- description: 'AI agent to assist with support cases',
-
- role: 'assistant',
-
+ role: 'Customer Service Specialist',
+
instructions: `You are a customer service AI agent.
-
+
Your responsibilities:
1. Triage incoming cases
2. Suggest relevant knowledge articles
@@ -178,67 +137,30 @@ Always be empathetic and solution-focused.`,
temperature: 0.5,
maxTokens: 1500,
},
-
+
+ // `search_knowledge` is provided by the Knowledge Protocol tool, not declared inline.
tools: [
- {
- name: 'triage_case',
- description: 'Analyze case and assign priority',
- parameters: {
- case_id: 'string',
- },
- },
- {
- name: 'search_knowledge',
- description: 'Search knowledge base for solutions',
- parameters: {
- query: 'string',
- },
- },
- {
- name: 'generate_response',
- description: 'Generate customer response',
- parameters: {
- case_id: 'string',
- tone: 'string',
- },
- },
+ { type: 'flow', name: 'triage_case', description: 'Analyze a case and assign priority' },
+ { type: 'action', name: 'generate_response', description: 'Generate a customer response' },
],
-
+
knowledge: {
- sources: [
- {
- type: 'object',
- objectName: 'case',
- fields: ['*'],
- },
- {
- type: 'document',
- path: '/knowledge/support-kb/**/*.md',
- },
- ],
+ topics: ['support-kb', 'cases'],
+ indexes: ['support_docs'],
},
-
- triggers: [
- {
- type: 'object_create',
- objectName: 'case',
- },
- ],
-};
+});
```
-### Lead Enrichment Agent (Worker)
+### Lead Enrichment Agent
```typescript
-export const LeadEnrichmentAgent: Agent = {
+export const LeadEnrichmentAgent = defineAgent({
name: 'lead_enrichment',
label: 'Lead Enrichment Agent',
- description: 'Automatically enrich lead data',
-
- role: 'worker',
-
+ role: 'Data Enrichment Worker',
+
instructions: `You enrich lead records with additional data.
-
+
Tasks:
1. Look up company information
2. Enrich contact details
@@ -253,60 +175,35 @@ Use reputable data sources.`,
temperature: 0.3,
maxTokens: 1000,
},
-
+
tools: [
- {
- name: 'lookup_company',
- description: 'Look up company information',
- parameters: {
- company_name: 'string',
- domain: 'string',
- },
- },
- {
- name: 'enrich_contact',
- description: 'Enrich contact information',
- parameters: {
- email: 'string',
- },
- },
- ],
-
- triggers: [
- {
- type: 'object_create',
- objectName: 'lead',
- },
+ { type: 'flow', name: 'lookup_company', description: 'Look up company information' },
+ { type: 'flow', name: 'enrich_contact', description: 'Enrich contact information' },
],
-
- schedule: {
- type: 'cron',
- expression: '0 */4 * * *', // Every 4 hours
- timezone: 'UTC',
- },
-};
+});
```
-### Revenue Intelligence Agent (Analyst)
+To run enrichment when a lead is created or on a schedule, trigger this agent
+from a [Flow or Workflow](./automation) — agents themselves carry no
+`triggers`/`schedule` fields.
+
+### Revenue Intelligence Agent
```typescript
-export const RevenueIntelligenceAgent: Agent = {
+export const RevenueIntelligenceAgent = defineAgent({
name: 'revenue_intelligence',
label: 'Revenue Intelligence Agent',
- description: 'Analyze pipeline and provide insights',
-
- role: 'analyst',
-
+ role: 'Revenue Operations Analyst',
+
instructions: `You analyze sales data and provide insights.
-
+
Responsibilities:
1. Analyze pipeline health
2. Identify at-risk deals
-3. Forecast revenue
-4. Detect anomalies
-5. Generate executive summaries
+3. Summarize trends
+4. Generate executive summaries
-Use statistical analysis and ML.`,
+Use the data tools to query records and aggregate metrics.`,
model: {
provider: 'openai',
@@ -314,46 +211,18 @@ Use statistical analysis and ML.`,
temperature: 0.2,
maxTokens: 3000,
},
-
+
+ // Built-in data tools (query_records / get_record / aggregate_data) are
+ // available to agents automatically once registered — see "Actions as Tools".
tools: [
- {
- name: 'analyze_pipeline',
- description: 'Analyze sales pipeline health',
- parameters: {
- user_id: 'string',
- time_period: 'string',
- },
- },
- {
- name: 'forecast_revenue',
- description: 'Generate revenue forecast',
- parameters: {
- time_period: 'string',
- method: 'string',
- },
- },
+ { type: 'flow', name: 'analyze_pipeline', description: 'Analyze sales pipeline health' },
],
-
+
knowledge: {
- sources: [
- {
- type: 'object',
- objectName: 'opportunity',
- fields: ['*'],
- },
- {
- type: 'analytics',
- dashboardName: 'sales_dashboard',
- },
- ],
- },
-
- schedule: {
- type: 'cron',
- expression: '0 8 * * 1', // Monday at 8am
- timezone: 'America/Los_Angeles',
+ topics: ['opportunities', 'pipeline'],
+ indexes: ['sales_docs'],
},
-};
+});
```
---
@@ -558,11 +427,13 @@ Omit `toolExecutionContext` to keep the previous system-level behaviour
### LLM-generated conversation titles
-Whenever the assistant has produced its **second** message in a
-conversation that still has no title, the AI service fires a short,
-out-of-band LLM call (`≤ 20 characters`, single-line, no quotes) to
-summarise what the user is asking about and writes the result back to
-`ai_conversations.title`. The summarise call:
+Auto-titling is **opt-in** (disabled by default; enable it via the `ai`
+settings namespace from Console → Settings → AI). Once enabled, after a
+conversation has at least one user + assistant exchange (≥ 2 messages) and
+still has no title, the AI service fires a short, out-of-band LLM call
+(default cap **16 characters**, single-line, no quotes) to summarise what the
+user is asking about and writes the result back to `ai_conversations.title`.
+The summarise call:
- Reuses the **active chat provider** (so it works with any
`provider/model` you configured via Console → Settings → AI), and
@@ -639,347 +510,26 @@ Mature OSS (RAGFlow, LlamaIndex, Dify) already nail chunking, embedding, hybrid
---
-## RAG Pipelines
-
-Retrieval-Augmented Generation (RAG) provides AI with access to your knowledge base.
-
-### Sales Knowledge RAG
-
-```typescript
-import type { RagPipeline } from '@objectstack/spec/ai';
-
-export const SalesKnowledgeRAG: RagPipeline = {
- name: 'sales_knowledge',
- label: 'Sales Knowledge Pipeline',
- description: 'Sales playbook and best practices',
-
- // Define indexes
- indexes: [
- {
- name: 'sales_playbook_index',
- type: 'vector',
-
- // Data sources
- sources: [
- {
- type: 'document',
- path: '/knowledge/sales/**/*.md',
- watch: true, // Auto-update on changes
- },
- {
- type: 'document',
- path: '/knowledge/products/**/*.pdf',
- watch: true,
- },
- {
- type: 'object',
- objectName: 'opportunity',
- fields: ['name', 'description', 'stage', 'amount'],
- filter: {
- stage: 'closed_won',
- close_date: { $gte: '{last_12_months}' },
- },
- },
- ],
-
- // Embedding model
- embedding: {
- provider: 'openai',
- model: 'text-embedding-3-large',
- dimensions: 1536,
- },
-
- // Chunking strategy
- chunking: {
- strategy: 'semantic',
- chunkSize: 1000,
- chunkOverlap: 200,
- },
-
- // Metadata extraction
- metadata: {
- extractors: [
- {
- type: 'title',
- source: 'filename',
- },
- {
- type: 'date',
- source: 'modified_date',
- },
- ],
- },
- },
- ],
-
- // Retrieval strategy
- retrieval: {
- strategy: 'hybrid',
-
- vectorSearch: {
- topK: 10,
- scoreThreshold: 0.7,
- algorithm: 'cosine',
- },
-
- keywordSearch: {
- enabled: true,
- weight: 0.3,
- },
-
- reranking: {
- enabled: true,
- model: 'cohere-rerank',
- topK: 5,
- },
- },
-
- // Generation
- generation: {
- model: {
- provider: 'openai',
- model: 'gpt-4',
- temperature: 0.7,
- maxTokens: 2000,
- },
-
- promptTemplate: `You are a sales expert. Use the context to answer.
-
-Context:
-{context}
-
-Question: {question}
-
-Answer based on the context. If uncertain, say so.`,
- },
-
- // Caching
- caching: {
- enabled: true,
- ttl: 3600, // 1 hour
- },
-};
-```
-
-### Support Knowledge RAG
-
-```typescript
-export const SupportKnowledgeRAG: RagPipeline = {
- name: 'support_knowledge',
- label: 'Support Knowledge Pipeline',
- description: 'Customer support knowledge base',
-
- indexes: [
- {
- name: 'support_kb_index',
- type: 'vector',
-
- sources: [
- {
- type: 'document',
- path: '/knowledge/support/**/*.md',
- watch: true,
- },
- {
- type: 'object',
- objectName: 'case',
- fields: ['subject', 'description', 'resolution'],
- filter: {
- is_closed: true,
- resolution: { $ne: null },
- },
- },
- ],
-
- embedding: {
- provider: 'openai',
- model: 'text-embedding-3-small',
- dimensions: 768,
- },
-
- chunking: {
- strategy: 'fixed',
- chunkSize: 512,
- chunkOverlap: 100,
- },
- },
- ],
-
- retrieval: {
- strategy: 'vector_only',
-
- vectorSearch: {
- topK: 5,
- scoreThreshold: 0.75,
- algorithm: 'cosine',
- },
- },
-
- generation: {
- model: {
- provider: 'openai',
- model: 'gpt-4',
- temperature: 0.3,
- maxTokens: 1500,
- },
-
- promptTemplate: `You are a support specialist. Help resolve customer issues.
-
-Knowledge Base:
-{context}
-
-Issue: {question}
-
-Provide a clear, step-by-step solution.`,
- },
-};
-```
-
----
-
## Natural Language Queries
-Allow users to query data using natural language.
-
-### NLQ Configuration
+Agents query your data through the built-in **data tools** —
+`query_records`, `get_record`, and `aggregate_data` — which the LLM calls with
+structured arguments. These run as ordinary [ObjectQL](./objectql) queries over
+your objects (ObjectStack uses ObjectQL, not SOQL), and they execute under the
+caller's `ExecutionContext`, so row-level security applies exactly as it does
+for the REST API.
```typescript
-import type { NLQConfig } from '@objectstack/spec/ai';
-
-export const CrmNLQConfig: NLQConfig = {
- name: 'crm_nlq',
- label: 'CRM Natural Language Queries',
-
- // Objects available for querying
- objects: [
- 'account',
- 'contact',
- 'lead',
- 'opportunity',
- 'case',
- ],
-
- // LLM for query translation
- model: {
- provider: 'openai',
- model: 'gpt-4',
- temperature: 0,
- },
-
- // Example queries for training
- examples: [
- {
- query: 'Show me all high-value opportunities closing this quarter',
- soql: 'SELECT Name, Amount, CloseDate FROM Opportunity WHERE Amount > 100000 AND CloseDate >= THIS_QUARTER',
- },
- {
- query: 'Which accounts have the most open cases?',
- soql: 'SELECT Account.Name, COUNT(Id) FROM Case WHERE IsClosed = false GROUP BY Account.Name ORDER BY COUNT(Id) DESC',
- },
- ],
-};
-```
-
-### Using NLQ
-
-```typescript
-// User asks in natural language
-const query = "Show me my top 10 opportunities by amount";
-
-// AI translates to SOQL
-const soql = await nlq.translate(query);
-// Result: SELECT Name, Amount FROM Opportunity WHERE OwnerId = '{userId}' ORDER BY Amount DESC LIMIT 10
+import { registerDataTools } from '@objectstack/service-ai';
-// Execute query
-const results = await execute(soql);
-```
-
----
-
-## Predictive Analytics
-
-Use machine learning for predictions and recommendations.
-
-### Lead Scoring
-
-```typescript
-import type { PredictiveModel } from '@objectstack/spec/ai';
-
-export const LeadScoringModel: PredictiveModel = {
- name: 'lead_scoring',
- label: 'Lead Scoring Model',
- description: 'Predict lead conversion probability',
-
- type: 'classification',
-
- trainingData: {
- objectName: 'lead',
- features: [
- 'annual_revenue',
- 'number_of_employees',
- 'industry',
- 'lead_source',
- 'rating',
- ],
- label: 'is_converted',
- filter: {
- created_date: { $gte: '{last_12_months}' },
- },
- },
-
- model: {
- algorithm: 'gradient_boosting',
- hyperparameters: {
- n_estimators: 100,
- learning_rate: 0.1,
- max_depth: 5,
- },
- },
-
- deployment: {
- mode: 'realtime',
- trigger: 'on_create',
- outputField: 'conversion_score',
- },
-};
+ctx.hook('ai:ready', async (ai) => {
+ registerDataTools(ai.toolRegistry, { dataEngine: ctx.getService('data') });
+});
```
-### Revenue Forecasting
-
-```typescript
-export const RevenueForecastModel: PredictiveModel = {
- name: 'revenue_forecast',
- label: 'Revenue Forecasting Model',
- description: 'Forecast monthly revenue',
-
- type: 'regression',
-
- trainingData: {
- objectName: 'opportunity',
- features: [
- 'amount',
- 'probability',
- 'stage',
- 'age_days',
- 'account.annual_revenue',
- ],
- label: 'close_date',
- filter: {
- stage: { $in: ['closed_won', 'closed_lost'] },
- },
- },
-
- model: {
- algorithm: 'time_series',
- method: 'prophet',
- },
-
- deployment: {
- mode: 'batch',
- schedule: '0 0 1 * *', // Monthly
- },
-};
-```
+There is no separate natural-language-to-query metadata type to author — the
+model is prompted with the available objects and translates the user's question
+into `query_records` / `aggregate_data` calls at runtime.
---
@@ -1000,20 +550,18 @@ export const RevenueForecastModel: PredictiveModel = {
- Deploy without testing
- Ignore cost implications
-### 2. RAG Pipeline Design
+### 2. Knowledge & Data Access
✅ **DO:**
-- Use semantic chunking for better context
-- Implement hybrid search (vector + keyword)
-- Enable reranking for accuracy
-- Cache frequently accessed content
-- Monitor retrieval quality
+- 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
+- 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
❌ **DON'T:**
-- Chunk too large or too small
-- Rely solely on vector search
-- Skip metadata extraction
-- Forget to update indexes
+- Reinvent a vector DB — let the adapter engine handle chunking/embedding/rerank
+- Bypass `ExecutionContext`, which would leak rows past row-level security
+- Expose objects to agents that the calling user can't read
### 3. Prompt Engineering
@@ -1066,41 +614,45 @@ export const RevenueForecastModel: PredictiveModel = {
### Complete Sales AI Workflow
+Agents are metadata, not classes — there are no `.enrich()` / `.predict()` /
+`.query()` methods to call. You invoke an agent over HTTP (the REST chat
+endpoint) or, server-side, via `aiService.chatWithTools(...)`. Enrichment,
+scoring, and email drafting are implemented as **Actions/Flows exposed as
+tools**, and the LLM calls them while reasoning over the conversation.
+
```typescript
-// 1. Lead comes in
-LeadEnrichmentAgent.enrich(lead);
-
-// 2. AI scores the lead
-score = LeadScoringModel.predict(lead);
-lead.conversion_score = score;
-
-// 3. If hot, sales assistant qualifies
-if (score > 80) {
- analysis = SalesAssistantAgent.analyze(lead);
- task = createTask({
- subject: `Follow up on hot lead: ${lead.name}`,
- priority: 'high',
- assignedTo: lead.owner,
- });
-}
-
-// 4. Sales rep asks for help
-response = SalesKnowledgeRAG.query({
- question: "What's our pitch for fintech companies?",
- context: { industry: 'finance' },
+// Invoke an agent over the REST chat endpoint.
+// POST /api/v1/ai/agents/:agentName/chat
+const res = await fetch('/api/v1/ai/agents/sales_assistant/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
+ body: JSON.stringify({
+ message: `A hot lead just came in (id ${lead.id}). Qualify it, suggest the
+ next best action, and draft a professional intro email.`,
+ }),
});
+const { reply } = await res.json();
+// The agent calls its `analyze_lead` / `suggest_next_action` / `generate_email`
+// tools (the Actions/Flows you wired) and returns its summary in `reply`.
+```
-// 5. Generate personalized email
-email = SalesAssistantAgent.generateEmail({
- recipient: lead,
- context: analysis,
- tone: 'professional',
-});
+Server-side, the same flow runs through `chatWithTools`, threading the
+end-user's `ExecutionContext` so tool calls respect row-level security:
-// 6. Track in pipeline
-RevenueIntelligenceAgent.analyzePipeline();
+```typescript
+const reply = await aiService.chatWithTools(messages, tools, {
+ toolExecutionContext: {
+ actor: { id: currentUser.id, name: currentUser.displayName, roles: currentUser.roles, permissions: currentUser.permissions },
+ conversationId,
+ environmentId,
+ },
+});
```
+To run any of this on lead creation or on a schedule, drive the agent from a
+[Flow or Workflow](./automation) rather than expecting trigger/schedule fields
+on the agent itself.
+
---
**Next:** [Coding Standards →](/docs/guides/standards)
diff --git a/content/docs/guides/api-reference.mdx b/content/docs/guides/api-reference.mdx
index dccb93b5ea..6e3f141ab5 100644
--- a/content/docs/guides/api-reference.mdx
+++ b/content/docs/guides/api-reference.mdx
@@ -70,7 +70,7 @@ Returns the full discovery manifest.
Alias for the discovery endpoint used by auto-discovery in the client SDK.
-**Service Status Values**: `available` (fully operational), `degraded` (partial functionality), `unavailable` (not installed), `stub` (placeholder that throws errors)
+**Service Status Values**: `available` (fully operational), `registered` (route declared but handler unverified — may return 501), `degraded` (partial functionality), `unavailable` (not installed), `stub` (placeholder that throws errors)
---
@@ -219,13 +219,13 @@ Execute a batch operation (create / update / upsert / delete) on multiple record
}
```
-**Response**: `BatchUpdateResponse` with `succeeded`, `failed`, `errors`, and optionally `records`.
+**Response**: `BatchUpdateResponse` with `succeeded`, `failed`, `total`, and a per-record `results` array. Each entry in `results` has `id`, `success`, an optional `errors` array, and optional `data` (the full record, present when `returnRecords` is `true`).
### `POST /data/:object/createMany`
Batch create multiple records.
-**Body**: `{ records: [{ name: "A" }, { name: "B" }] }`
+**Body**: a bare array of records — `[{ name: "A" }, { name: "B" }]`. The REST handler reads the request body directly as the records array, so do **not** wrap it in `{ records: [...] }`.
**Response**: `{ object: "account", records: [...], count: 2 }`
### `POST /data/:object/updateMany`
@@ -265,21 +265,30 @@ Execute an analytics query.
"cube": "account",
"measures": ["revenue.sum", "count"],
"dimensions": ["industry"],
- "filters": [{ "member": "status", "operator": "equals", "values": ["active"] }],
+ "where": { "status": "active" },
"limit": 100
}
```
-**Response**:
+
+Filtering uses the canonical Query DSL `where` object (the same MongoDB-style `FilterCondition` accepted by `find()`), not a `filters` array.
+
+
+**Response**: the runtime dispatcher wraps the `AnalyticsResult` as `{ success: true, data: { rows, fields, sql?, totals? } }`:
```json
{
- "data": [
- { "industry": "Technology", "revenue.sum": 150000, "count": 5 },
- { "industry": "Healthcare", "revenue.sum": 80000, "count": 3 }
- ],
- "annotation": {
- "measures": { "revenue.sum": { "title": "Revenue Sum", "type": "number" } },
- "dimensions": { "industry": { "title": "Industry", "type": "string" } }
+ "success": true,
+ "data": {
+ "rows": [
+ { "industry": "Technology", "revenue.sum": 150000, "count": 5 },
+ { "industry": "Healthcare", "revenue.sum": 80000, "count": 3 }
+ ],
+ "fields": [
+ { "name": "industry", "type": "string", "label": "Industry" },
+ { "name": "revenue.sum", "type": "number", "label": "Revenue Sum", "format": "$0,0" },
+ { "name": "count", "type": "number", "label": "Count" }
+ ],
+ "sql": "SELECT ..."
}
}
```
@@ -361,7 +370,7 @@ The following endpoints become available when the corresponding plugin is instal
| Method | Endpoint | Description |
|:-------|:---------|:------------|
-| POST | `/automation/trigger` | Trigger an automation flow |
+| POST | `/automation/trigger/:name` | Trigger an automation flow by name |
### Views (`/ui`) — Plugin Required
@@ -388,13 +397,13 @@ The following endpoints become available when the corresponding plugin is instal
| Method | Endpoint | Description |
|:-------|:---------|:------------|
-| GET | `/notifications` | List notifications |
-| POST | `/notifications/read` | Mark as read |
-| POST | `/notifications/read-all` | Mark all as read |
-| POST | `/notifications/devices` | Register device |
-| DELETE | `/notifications/devices/:id` | Unregister device |
-| GET | `/notifications/preferences` | Get preferences |
-| PATCH | `/notifications/preferences` | Update preferences |
+| GET | `/notifications` | List notifications (inbox) |
+| POST | `/notifications/read` | Mark notifications as read (body: `{ ids: string[] }`) |
+| POST | `/notifications/read/all` | Mark all as read |
+
+
+The core dispatcher implements only the list / read / read-all routes above. Device-registration and preference endpoints are provided by specific notification plugins, if any.
+
### AI (`/ai`) — Plugin Required
@@ -410,8 +419,8 @@ The following endpoints become available when the corresponding plugin is instal
| Method | Endpoint | Description |
|:-------|:---------|:------------|
| GET | `/i18n/locales` | List available locales |
-| GET | `/i18n/translations?locale=:locale` | Get translation bundle |
-| GET | `/i18n/labels/:object?locale=:locale` | Get field labels |
+| GET | `/i18n/translations/:locale` | Get translation bundle |
+| GET | `/i18n/labels/:object/:locale` | Get field labels |
### GraphQL (`/graphql`) — Plugin Required
@@ -424,41 +433,48 @@ The following endpoints become available when the corresponding plugin is instal
| Method | Endpoint | Description |
|:-------|:---------|:------------|
| POST | `/storage/upload` | Upload a file |
-| GET | `/storage/:id` | Download a file |
-| DELETE | `/storage/:id` | Delete a file |
+| GET | `/storage/file/:id` | Download a file |
---
## Error Handling
-All error responses follow a standardized format:
+Error responses depend on which HTTP server is in front of the kernel. There are two wire formats in use today.
+
+**Kernel REST server** (`@objectstack/rest`) emits a string `error` message plus a SCREAMING_SNAKE `code`:
+
+```json
+{
+ "error": "Record not found: account/123",
+ "code": "RECORD_NOT_FOUND"
+}
+```
+
+Validation failures additionally include an `issues` array. Common codes emitted by the kernel REST server:
+
+| Code | HTTP | Description |
+|:-----|:-----|:------------|
+| `VALIDATION_FAILED` | 400 | Input validation failed (includes `issues`) |
+| `PERMISSION_DENIED` | 403 | Insufficient permissions |
+| `RECORD_NOT_FOUND` | 404 | Resource does not exist |
+| `CONCURRENT_UPDATE` | 409 | Record was modified by another user |
+
+**Runtime dispatcher** (`@objectstack/runtime`) wraps errors in the `{ success: false, ... }` envelope, where `code` is the numeric HTTP status:
```json
{
+ "success": false,
"error": {
- "code": "resource_not_found",
"message": "Record not found: account/123",
- "httpStatus": 404,
- "category": "request",
- "retryable": false,
+ "code": 404,
"details": {}
}
}
```
-### Error Codes
-
-| Code | HTTP | Category | Retryable | Description |
-|:-----|:-----|:---------|:----------|:------------|
-| `validation_error` | 400 | validation | No | Input validation failed |
-| `invalid_query` | 400 | validation | No | Malformed query |
-| `unauthenticated` | 401 | auth | No | Authentication required |
-| `permission_denied` | 403 | auth | No | Insufficient permissions |
-| `resource_not_found` | 404 | request | No | Resource does not exist |
-| `conflict` | 409 | request | No | Resource conflict (e.g. duplicate) |
-| `rate_limit_exceeded` | 429 | rate_limit | Yes | Too many requests |
-| `internal_error` | 500 | server | Yes | Server error |
-| `service_unavailable` | 503 | server | Yes | Service temporarily unavailable |
+
+The richer `ErrorResponseSchema` in `@objectstack/spec/api` (with `category`, `retryable`, etc.) is the aspirational spec envelope, not the current wire format. Its `category` values are drawn from the `ErrorCategory` enum: `validation`, `authentication`, `authorization`, `not_found`, `conflict`, `rate_limit`, `server`, `external`, `maintenance`.
+
---
@@ -481,7 +497,7 @@ const request = FindDataRequestSchema.parse({ object: 'account', query: { ... }
const response: FindDataResponse = await protocol.findData(request);
```
-See the [Protocol Reference](../references/api/protocol) for the complete list of 57 protocol methods and their Zod schemas.
+See the [Protocol Reference](../references/api/protocol) for the full list of protocol methods and their Zod schemas.
---
diff --git a/content/docs/guides/auth-sso.mdx b/content/docs/guides/auth-sso.mdx
index 31c12bb1d4..ea0f2fe716 100644
--- a/content/docs/guides/auth-sso.mdx
+++ b/content/docs/guides/auth-sso.mdx
@@ -5,10 +5,12 @@ description: Enable Google sign-in in open source, and extend auth providers thr
# Social & Enterprise SSO
-ObjectStack open source ships one built-in social sign-in implementation:
+ObjectStack open source ships two built-in social sign-in implementations, both wired by `os serve` from deployment env vars:
- **Google OAuth**: configure in Setup → Authentication, or from
`GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` deployment env vars
+- **GitHub OAuth**: configure from `GITHUB_CLIENT_ID` and
+ `GITHUB_CLIENT_SECRET` deployment env vars
Additional providers should be contributed by product or enterprise packages through
the `auth:configure` hook. Those packages can add better-auth `socialProviders`
@@ -43,13 +45,7 @@ GOOGLE_CLIENT_SECRET=your-google-client-secret
# OS_AUTH_GOOGLE_ENABLED=false
```
-## Extension Packages
-
-The providers below are examples for enterprise or product packages, not built-in
-open-source settings. A package can register them by listening to `auth:configure`
-and mutating the draft auth config.
-
-### GitHub Example
+### GitHub
1. Go to [GitHub Developer Settings](https://github.com/settings/developers) → **OAuth Apps** → **New OAuth App**.
2. Set **Authorization callback URL**:
@@ -58,11 +54,24 @@ and mutating the draft auth config.
```
3. Copy the **Client ID** and generate a **Client Secret**.
+Provide them as deployment environment variables:
+
```bash
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
```
+## Extension Packages
+
+The providers below are examples for enterprise or product packages, not built-in
+open-source settings. A package can register them by listening to `auth:configure`
+and mutating the draft auth config.
+
+The env-var names shown below (e.g. `MICROSOFT_CLIENT_ID`, `APPLE_CLIENT_ID`) are
+illustrative for an extension package's own reader code — unlike `GOOGLE_*` and
+`GITHUB_*`, the framework does **not** read them out of the box, so an extension
+must wire them in its `auth:configure` handler.
+
### Microsoft (Azure AD / Entra ID)
1. Go to [Azure Portal](https://portal.azure.com/) → **Azure Active Directory** → **App registrations** → **New registration**.
@@ -197,7 +206,7 @@ Restart the server (`pnpm dev`), then:
curl http://localhost:3000/api/v1/auth/config
```
-Expected response:
+Expected response (abbreviated — `getPublicConfig()` also returns `disableSignUp`/`requireEmailVerification` on `emailPassword` and additional `features` keys such as `multiOrgEnabled`, `oidcProvider`, `deviceAuthorization`, and `admin`):
```json
{
@@ -234,7 +243,7 @@ The Studio `/login` and `/register` pages will now show a button for each enable
## Notes
-- `AUTH_SECRET` must be set to a random string of at least 32 characters in production.
-- `NEXT_PUBLIC_BASE_URL` / `VERCEL_URL` must match the domain registered with each provider.
+- `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_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/guides/authentication.mdx b/content/docs/guides/authentication.mdx
index 3ad9ecf015..95a547a987 100644
--- a/content/docs/guides/authentication.mdx
+++ b/content/docs/guides/authentication.mdx
@@ -34,7 +34,6 @@ The `@objectstack/plugin-auth` package provides enterprise-grade authentication
- ✅ **Password Reset** - Email-based password reset flow
- ✅ **Email Verification** - Email verification workflow
- ⚙️ **2FA backend** - better-auth two-factor plugin wiring is available for custom UIs
-- ✅ **Passkeys** - WebAuthn/Passkey support
- ✅ **Magic Links** - Passwordless authentication
- ✅ **Organizations** - Multi-tenant support
- ✅ **ObjectQL Integration** - Native ObjectStack data persistence (no ORM required)
@@ -50,23 +49,25 @@ The plugin uses a **direct forwarding** architecture where all authentication re
### CLI device flow
-`os auth login` uses a browser-based device flow in interactive terminals. The
-CLI requests a one-time code from `POST /api/v1/auth/device/request`, opens the
-Studio approval page at `/_studio/auth/device?code=...`, and polls
-`GET /api/v1/auth/device/token` until the user approves access. Device codes
-expire after five minutes.
+`os login` uses a browser-based device flow in interactive terminals. The
+CLI requests a one-time code from `POST /api/v1/auth/device/code`, opens the
+Console approval page at `/_console/auth/device?user_code=...`, and polls
+`POST /api/v1/auth/device/token` until the user approves access. Device codes
+expire after the server-configured TTL (the CLI assumes a 10-minute / 600s
+default). The device flow requires `plugins: { deviceAuthorization: true }` in
+your `AuthPlugin` configuration.
The email/password path is still supported for CI and non-interactive shells:
```bash
-os auth login --email user@example.com --password secret
+os login --email user@example.com --password secret
```
New accounts can be created with:
```bash
-os auth register
-os auth register --email user@example.com --name "Jane Doe" --password secret
+os register
+os register --email user@example.com --name "Jane Doe" --password secret
```
---
@@ -111,11 +112,19 @@ Add the plugin to your kernel configuration:
```typescript
import { ObjectKernel } from '@objectstack/core';
+import { ObjectQLPlugin } from '@objectstack/objectql';
+import { DriverPlugin } from '@objectstack/runtime';
+import { InMemoryDriver } from '@objectstack/driver-memory';
import { AuthPlugin } from '@objectstack/plugin-auth';
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
const kernel = new ObjectKernel();
+// AuthPlugin depends on the ObjectQL engine for data persistence,
+// so register ObjectQLPlugin and a driver first.
+await kernel.use(new ObjectQLPlugin());
+await kernel.use(new DriverPlugin(new InMemoryDriver(), 'memory'));
+
// HTTP server (optional — auth works without it in MSW/mock mode)
await kernel.use(new HonoServerPlugin({
port: 3000,
@@ -123,7 +132,7 @@ await kernel.use(new HonoServerPlugin({
// Authentication plugin
await kernel.use(new AuthPlugin({
- secret: process.env.AUTH_SECRET,
+ secret: process.env.OS_AUTH_SECRET,
baseUrl: 'http://localhost:3000',
}));
@@ -207,7 +216,7 @@ console.log('Current user:', session.data.user);
```typescript
// Direct API call
-const response = await fetch('http://localhost:3000/api/v1/auth/forget-password', {
+const response = await fetch('http://localhost:3000/api/v1/auth/request-password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -262,28 +271,51 @@ Enable OAuth providers in your plugin configuration:
new AuthPlugin({
secret: process.env.OS_AUTH_SECRET,
baseUrl: 'http://localhost:3000',
- providers: [
- {
- id: 'google',
+ // socialProviders is a record keyed by provider id (forwarded to better-auth).
+ socialProviders: {
+ google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
- {
- id: 'github',
+ github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
- }
- ]
+ },
+ },
})
```
+> **Note:** Use the `socialProviders` record above. The legacy `providers: [...]`
+> array still type-checks but is **not** wired up at runtime — only `socialProviders`
+> is forwarded to better-auth, so a `providers` array produces no working OAuth.
+
+#### Zero-config Google (env only)
+
+You can enable Google sign-in without any code by setting `GOOGLE_CLIENT_ID` and
+`GOOGLE_CLIENT_SECRET` (and leaving `OS_AUTH_GOOGLE_ENABLED` unset or not `false`).
+The plugin auto-registers Google as a social provider from these environment
+variables. Use the explicit `socialProviders` record for other providers or to
+override the env-derived configuration.
+
### OAuth Flow
#### 1. Initiate OAuth Login
```typescript
-// Redirect user to OAuth provider
-window.location.href = 'http://localhost:3000/api/v1/auth/authorize/google';
+// Recommended: use the client SDK, which posts to /sign-in/social and redirects.
+await client.auth.signInWithProvider('google');
+
+// Equivalent direct API call: POST /sign-in/social returns a redirect URL.
+const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/social', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ provider: 'google',
+ callbackURL: window.location.origin + '/login',
+ }),
+});
+const data = await response.json();
+window.location.href = data.url ?? data.data.url;
```
#### 2. Handle Callback
@@ -309,7 +341,7 @@ For custom account UIs, enable the backend plugin in configuration:
```typescript
new AuthPlugin({
- secret: process.env.AUTH_SECRET,
+ secret: process.env.OS_AUTH_SECRET,
baseUrl: 'http://localhost:3000',
plugins: {
twoFactor: true, // Enable backend two-factor endpoints
@@ -349,38 +381,10 @@ const response = await fetch('http://localhost:3000/api/v1/auth/two-factor/verif
### Passkeys (WebAuthn)
-Enable passkey support:
-
-```typescript
-new AuthPlugin({
- secret: process.env.AUTH_SECRET,
- baseUrl: 'http://localhost:3000',
- plugins: {
- passkeys: true, // Enable passkey support
- }
-})
-```
-
-#### Register a Passkey
-
-```typescript
-const response = await fetch('http://localhost:3000/api/v1/auth/passkey/register', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${accessToken}`
- }
-});
-```
-
-#### Authenticate with Passkey
-
-```typescript
-const response = await fetch('http://localhost:3000/api/v1/auth/passkey/authenticate', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' }
-});
-```
+> **Not yet implemented.** Passkey/WebAuthn support is not currently wired into
+> `AuthPlugin`. The `plugins: { passkeys: true }` flag is accepted but no
+> passkey plugin is registered, so `/passkey/*` endpoints are not available.
+> This section will be updated once passkey support ships.
### Magic Links
@@ -388,7 +392,7 @@ Enable passwordless magic link authentication:
```typescript
new AuthPlugin({
- secret: process.env.AUTH_SECRET,
+ secret: process.env.OS_AUTH_SECRET,
baseUrl: 'http://localhost:3000',
plugins: {
magicLink: true, // Enable magic links
@@ -421,7 +425,7 @@ Enable organization/team support:
```typescript
new AuthPlugin({
- secret: process.env.AUTH_SECRET,
+ secret: process.env.OS_AUTH_SECRET,
baseUrl: 'http://localhost:3000',
plugins: {
organization: true, // Enable organizations
@@ -517,7 +521,7 @@ All endpoints are available under `/api/v1/auth/*`:
#### Password Management
-- `POST /api/v1/auth/forget-password` - Request password reset email
+- `POST /api/v1/auth/request-password-reset` - Request password reset email
- `POST /api/v1/auth/reset-password` - Reset password with token
#### Email Verification
@@ -527,7 +531,7 @@ All endpoints are available under `/api/v1/auth/*`:
#### OAuth
-- `GET /api/v1/auth/authorize/[provider]` - Start OAuth flow
+- `POST /api/v1/auth/sign-in/social` - Start OAuth flow (body `{ provider, callbackURL }`; returns a redirect URL)
- `GET /api/v1/auth/callback/[provider]` - OAuth callback handler
#### Two-Factor Authentication
@@ -535,11 +539,6 @@ All endpoints are available under `/api/v1/auth/*`:
- `POST /api/v1/auth/two-factor/enable` - Start 2FA enrollment for the current user
- `POST /api/v1/auth/two-factor/verify-totp` - Verify a TOTP code
-#### Passkeys
-
-- `POST /api/v1/auth/passkey/register` - Register a passkey
-- `POST /api/v1/auth/passkey/authenticate` - Authenticate with passkey
-
#### Magic Links
- `POST /api/v1/auth/magic-link/send` - Send magic link email
@@ -553,7 +552,7 @@ For complete API documentation, see the [Better-Auth API Reference](https://www.
### Security
-1. **Use Strong Secrets**: Generate a strong random secret for `AUTH_SECRET` (minimum 32 characters)
+1. **Use Strong Secrets**: Generate a strong random secret for `OS_AUTH_SECRET` (minimum 32 characters)
```bash
# Generate a secure secret
openssl rand -base64 32
@@ -578,7 +577,7 @@ For complete API documentation, see the [Better-Auth API Reference](https://www.
4. **Session Expiry**: Configure appropriate session expiry times
```typescript
new AuthPlugin({
- secret: process.env.AUTH_SECRET,
+ secret: process.env.OS_AUTH_SECRET,
baseUrl: 'http://localhost:3000',
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
@@ -671,7 +670,7 @@ await kernel.use(new AuthPlugin({
await kernel.bootstrap();
```
-> ⚠️ **Warning:** The secret above is for **local development only**. In production, always use a strong random secret from an environment variable (`process.env.AUTH_SECRET`).
+> ⚠️ **Warning:** The secret above is for **local development only**. In production, always use a strong random secret from an environment variable (`process.env.OS_AUTH_SECRET`).
### Mock Fallback Endpoints
@@ -722,6 +721,6 @@ await kernel.bootstrap();
Complete working examples are available in the repository:
-- [Basic Auth Example](https://github.com/objectstack-ai/spec/tree/main/packages/plugins/plugin-auth/examples/basic-usage.ts)
-- [Todo App with Auth](https://github.com/objectstack-ai/spec/tree/main/examples/app-todo)
+- [Basic Auth Example](https://github.com/objectstack-ai/framework/tree/main/packages/plugins/plugin-auth/examples/basic-usage.ts)
+- [Todo App with Auth](https://github.com/objectstack-ai/framework/tree/main/examples/app-todo)
- [CRM App with Auth](https://github.com/objectstack-ai/hotcrm)
diff --git a/content/docs/guides/business-logic.mdx b/content/docs/guides/business-logic.mdx
index 5dd682337f..5d4f8a26fc 100644
--- a/content/docs/guides/business-logic.mdx
+++ b/content/docs/guides/business-logic.mdx
@@ -11,7 +11,7 @@ Complete guide to implementing business rules, validations, and automated proces
1. [Validation Rules](#validation-rules)
2. [Flows](#flows)
-3. [Triggers](#triggers)
+3. [Hooks](#hooks)
4. [Approval Nodes](#approval-nodes)
5. [Formula Logic](#formula-logic)
6. [Best Practices](#best-practices)
@@ -24,7 +24,10 @@ Validation rules ensure data quality by preventing invalid data from being saved
### Script Validation
-Use JavaScript-like expressions to validate data:
+Validation conditions are **CEL** expressions evaluated against the record. The
+rule **fails** when the condition is `TRUE`, so phrase the condition as the
+*invalid* case. Use lowercase CEL stdlib functions (`today()`, `now()`,
+`isBlank()`, …) and `==`/`!=` for comparisons:
```typescript
validations: [
@@ -33,41 +36,40 @@ validations: [
type: 'script',
severity: 'error',
message: 'Close Date must be in the future',
- condition: 'close_date <= TODAY()',
+ condition: 'record.close_date <= today()',
},
-
+
{
name: 'discount_limit',
type: 'script',
severity: 'warning',
message: 'Discount exceeds 20%',
- condition: 'discount > 20',
+ condition: 'record.discount > 20',
},
]
```
-### Unique Validation
+### Unique Constraints
-Ensure field values are unique across records:
+There is no `unique` validation rule type. Uniqueness is enforced with a unique
+index (or field-level `unique: true`), not a validation rule:
```typescript
-{
- name: 'email_unique',
- type: 'unique',
- severity: 'error',
- message: 'Email address must be unique',
- fields: ['email'],
- caseSensitive: false,
-}
+// Field-level uniqueness
+Field.text({
+ label: 'Email',
+ unique: true,
+})
-// Compound unique constraint
-{
- name: 'account_product_unique',
- type: 'unique',
- severity: 'error',
- message: 'Product already exists for this account',
- fields: ['account', 'product'],
-}
+// Single-field unique index
+indexes: [
+ { fields: ['email'], unique: true },
+]
+
+// Compound unique index
+indexes: [
+ { fields: ['account', 'product'], unique: true },
+]
```
### Required Field Validation
@@ -86,23 +88,34 @@ Field.text({
type: 'script',
severity: 'error',
message: 'Contact is required for customer accounts',
- condition: 'type = "customer" AND ISBLANK(primary_contact)',
+ condition: 'record.type == "customer" && isBlank(record.primary_contact)',
}
```
-### Validation Functions
+### CEL Functions and Operators
+
+Conditions and formulas use CEL. Logic is expressed with operators (`&&`, `||`,
+`!`, `==`, `!=`, ternary `cond ? a : b`) rather than function calls. The current
+record is `record`; the pre-change snapshot is `previous`. The CEL stdlib
+functions available include:
| Function | Description | Example |
|----------|-------------|---------|
-| `ISBLANK(field)` | Check if field is empty | `ISBLANK(phone)` |
-| `ISCHANGED(field)` | Check if field changed | `ISCHANGED(stage)` |
-| `ISNEW()` | Check if record is new | `ISNEW()` |
-| `PRIORVALUE(field)` | Get previous value | `PRIORVALUE(status)` |
-| `AND(expr1, expr2)` | Logical AND | `AND(is_active, amount > 0)` |
-| `OR(expr1, expr2)` | Logical OR | `OR(status="new", status="pending")` |
-| `NOT(expr)` | Logical NOT | `NOT(is_deleted)` |
-| `TODAY()` | Current date | `TODAY()` |
-| `NOW()` | Current datetime | `NOW()` |
+| `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)` |
+| `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())` |
+| `upper(s)` / `lower(s)` | Case conversion | `upper(record.sku)` |
+| `trim(s)` | Strip whitespace | `trim(record.name)` |
+| `contains(s, sub)` / `startsWith` / `endsWith` | Substring checks | `contains(record.email, "@")` |
+| `matches(s, regex)` | Regex test | `matches(record.email, "^[^@]+@[^@]+$")` |
+| `len(x)` / `size(x)` | Length / size | `len(record.code) == 6` |
+| `min(a, b)` / `max(a, b)` / `abs(x)` / `round(x)` | Numerics | `max(record.amount, 0)` |
+
+To compare against a field's previous value, reference `previous.`
+(e.g. `record.stage != previous.stage`).
---
@@ -123,13 +136,17 @@ export const hotLeadFollowUp: Flow = {
label: 'Hot Lead Follow Up',
type: 'record_change',
status: 'active',
- trigger: {
- objectName: 'lead',
- operations: ['create'],
- condition: "record.rating == 'hot'",
- },
nodes: [
- { id: 'start', type: 'start', label: 'Start' },
+ {
+ id: 'start',
+ type: 'start',
+ label: 'Start',
+ config: {
+ triggerType: 'record-after-create',
+ objectName: 'lead',
+ condition: "record.rating == 'hot'",
+ },
+ },
{
id: 'create_task',
type: 'create_record',
@@ -168,15 +185,18 @@ export const hotLeadFollowUp: Flow = {
export const contractExpirationCheck: Flow = {
name: 'contract_expiration_check',
label: 'Contract Expiration Check',
- type: 'scheduled',
+ type: 'schedule',
status: 'active',
- schedule: {
- type: 'cron',
- expression: '0 0 * * *',
- timezone: 'UTC',
- },
nodes: [
- { id: 'start', type: 'start', label: 'Start' },
+ {
+ id: 'start',
+ type: 'start',
+ label: 'Start',
+ config: {
+ triggerType: 'schedule',
+ schedule: { type: 'cron', expression: '0 0 * * *', timezone: 'UTC' },
+ },
+ },
{ id: 'find_expiring', type: 'get_record', label: 'Find Expiring Contracts' },
{ id: 'notify_owners', type: 'notify', label: 'Notify Owners' },
{ id: 'end', type: 'end', label: 'End' },
@@ -193,10 +213,14 @@ export const contractExpirationCheck: Flow = {
| Node | Use case |
|------|----------|
+| `start`, `end` | Flow entry (carries the trigger config) and exit |
| `decision` | Branch based on CEL predicates |
| `assignment` | Set variables |
+| `script` | Run an inline expression / function |
+| `loop`, `map` | Iterate over a collection |
| `create_record`, `update_record`, `delete_record`, `get_record` | CRUD |
-| `http` | Outbound HTTP call |
+| `http` | Outbound HTTP call (`http_request` is a deprecated alias) |
+| `connector_action` | Invoke a registered connector action |
| `notify` | In-app/email-style notification dispatch |
| `wait`, `screen`, `approval` | Durable pauses |
| `subflow` | Invoke another flow |
@@ -204,98 +228,91 @@ export const contractExpirationCheck: Flow = {
---
-## Triggers
+## Hooks
+
+Hooks are the data-layer "logic layer": they run custom code at interception
+points in the ObjectQL execution pipeline (before/after insert, update, delete,
+find, etc.). The construct is `Hook`, imported from `@objectstack/spec/data`.
-Advanced event-driven automation with custom logic.
+A hook targets one or more `object`s and subscribes to one or more lifecycle
+`events`. Each event is a combined timing+action enum value, e.g.
+`beforeInsert`, `beforeUpdate`, `afterUpdate`, `afterDelete` (read-side events
+such as `beforeFind`/`afterFind` are also available).
-### Before Trigger
+### Before Hook
-Modify records before they're saved:
+Mutate the incoming record before it is saved. The pending record lives on
+`ctx.input` (`ctx.input.doc` for insert/update):
```typescript
-import { Trigger } from '@objectstack/spec/data';
-
-export const AccountBeforeTrigger: Trigger = {
- name: 'account_before_insert_update',
- objectName: 'account',
- timing: 'before',
- operations: ['insert', 'update'],
-
- handler: async (context) => {
- for (const record of context.records) {
- // Normalize phone numbers
- if (record.phone) {
- record.phone = normalizePhone(record.phone);
- }
-
- // Auto-populate from website
- if (!record.industry && record.website) {
- record.industry = await lookupIndustry(record.website);
- }
+import { Hook } from '@objectstack/spec/data';
+
+export const AccountBeforeWrite: Hook = {
+ name: 'account_before_write',
+ object: 'account',
+ events: ['beforeInsert', 'beforeUpdate'],
+
+ handler: async (ctx) => {
+ const record = ctx.input.doc as Record;
+
+ // Normalize phone numbers
+ if (record.phone) {
+ record.phone = normalizePhone(record.phone);
+ }
+
+ // Auto-populate from website
+ if (!record.industry && record.website) {
+ record.industry = await lookupIndustry(record.website);
}
},
};
```
-### After Trigger
+### After Hook
-Perform actions after records are saved:
+React after a record is persisted. Use `ctx.previous` for the pre-change
+snapshot and `ctx.api.object('x')` for cross-object writes:
```typescript
-export const OpportunityAfterTrigger: Trigger = {
+export const OpportunityAfterUpdate: Hook = {
name: 'opportunity_after_update',
- objectName: 'opportunity',
- timing: 'after',
- operations: ['update'],
-
- handler: async (context) => {
- const wonOpps = context.records.filter(
- r => r.stage === 'closed_won' &&
- context.oldRecords[r.id].stage !== 'closed_won'
- );
-
- for (const opp of wonOpps) {
- // Create contract
- await context.create('contract', {
+ object: 'opportunity',
+ events: ['afterUpdate'],
+
+ handler: async (ctx) => {
+ const opp = ctx.result as Record;
+ const wasWon = ctx.previous?.stage === 'closed_won';
+
+ if (opp.stage === 'closed_won' && !wasWon) {
+ // Create a contract via the scoped cross-object API
+ await ctx.api.object('contract').insert({
account: opp.account,
opportunity: opp.id,
contract_value: opp.amount,
start_date: new Date(),
});
-
- // Send notification
- await context.sendEmail({
- to: opp.owner.email,
- template: 'opportunity_won',
- data: { opportunity: opp },
- });
}
},
};
```
-### Trigger Context
+### Hook Context
-Available in trigger handlers:
+`handler` receives a `HookContext` with these fields:
```typescript
-context = {
- records: Record[], // New records
- oldRecords: Map, // Original records (update/delete)
- operation: 'insert' | 'update' | 'delete',
- timing: 'before' | 'after',
- user: User, // Current user
-
- // Operations
- create(objectName, data),
- update(objectName, id, data),
- delete(objectName, id),
- query(objectName, filter),
-
- // Utilities
- sendEmail(options),
- callAPI(url, options),
- log(message),
+ctx = {
+ id, // tracing id
+ object, // target object name
+ event, // e.g. 'beforeInsert' | 'afterUpdate'
+ input, // mutable input (e.g. input.doc for insert/update)
+ result, // mutable operation result (after* events)
+ previous, // record state before the operation (update/delete)
+ session, // { userId, tenantId, roles, accessToken, isSystem }
+ user, // { id, name, email } convenience shortcut
+ transaction, // active transaction handle, if any
+ ql, // ObjectQL engine reference
+ api, // scoped cross-object access: ctx.api.object('x')
}
```
@@ -314,20 +331,24 @@ export const opportunityApproval: Flow = {
label: 'Opportunity Approval',
type: 'record_change',
status: 'active',
- trigger: {
- objectName: 'opportunity',
- operations: ['update'],
- condition: "record.amount >= 50000 && record.stage == 'proposal'",
- },
nodes: [
- { id: 'start', type: 'start', label: 'Start' },
+ {
+ id: 'start',
+ type: 'start',
+ label: 'Start',
+ config: {
+ triggerType: 'record-after-update',
+ objectName: 'opportunity',
+ condition: "record.amount >= 50000 && record.stage == 'proposal'",
+ },
+ },
{
id: 'manager_approval',
type: 'approval',
label: 'Manager Approval',
config: {
approvers: [{ type: 'user', value: '${record.owner_manager_id}' }],
- behavior: 'all',
+ behavior: 'unanimous',
approvalStatusField: 'approval_status',
lockRecord: true,
},
@@ -338,8 +359,8 @@ export const opportunityApproval: Flow = {
],
edges: [
{ id: 'e1', source: 'start', target: 'manager_approval' },
- { id: 'approved', source: 'manager_approval', target: 'mark_approved', condition: "approval.status == 'approved'" },
- { id: 'rejected', source: 'manager_approval', target: 'mark_rejected', condition: "approval.status == 'rejected'" },
+ { id: 'approved', source: 'manager_approval', target: 'mark_approved', label: 'approve' },
+ { id: 'rejected', source: 'manager_approval', target: 'mark_rejected', label: 'reject' },
{ id: 'e4', source: 'mark_approved', target: 'end' },
{ id: 'e5', source: 'mark_rejected', target: 'end' },
],
@@ -358,17 +379,18 @@ Complex calculations and logic in formula fields.
### Conditional Logic
+Formula fields are CEL. Use ternary `cond ? a : b` for conditionals and
+`&&`/`||`/`!` for boolean logic. Reference fields via `record.`:
+
```typescript
// Tiered pricing
Field.formula({
label: 'Discount Tier',
type: 'text',
formula: `
- IF(amount > 1000000, "Platinum",
- IF(amount > 500000, "Gold",
- IF(amount > 100000, "Silver", "Bronze")
- )
- )
+ record.amount > 1000000 ? "Platinum" :
+ record.amount > 500000 ? "Gold" :
+ record.amount > 100000 ? "Silver" : "Bronze"
`,
})
@@ -377,9 +399,8 @@ Field.formula({
label: 'Health Score',
type: 'text',
formula: `
- IF(AND(is_active, days_since_contact < 30), "Healthy",
- IF(days_since_contact < 90, "At Risk", "Critical")
- )
+ record.is_active && record.days_since_contact < 30 ? "Healthy" :
+ record.days_since_contact < 90 ? "At Risk" : "Critical"
`,
})
```
@@ -391,21 +412,21 @@ Field.formula({
Field.formula({
label: 'Days to Close',
type: 'number',
- formula: 'DAYS_DIFF(close_date, TODAY())',
+ formula: 'daysBetween(record.close_date, today())',
})
// Contract end in 30 days
Field.formula({
label: 'Expiring Soon',
type: 'boolean',
- formula: 'AND(status = "active", DAYS_DIFF(end_date, TODAY()) <= 30)',
+ formula: 'record.status == "active" && daysBetween(record.end_date, today()) <= 30',
})
-// Age in months
+// Age in days
Field.formula({
- label: 'Age (Months)',
+ label: 'Age (Days)',
type: 'number',
- formula: 'MONTH_DIFF(TODAY(), created_date)',
+ formula: 'daysBetween(today(), record.created_date)',
})
```
@@ -416,7 +437,7 @@ Field.formula({
Field.formula({
label: 'Gross Margin %',
type: 'percent',
- formula: 'IF(revenue > 0, ((revenue - cost) / revenue) * 100, 0)',
+ formula: 'record.revenue > 0 ? ((record.revenue - record.cost) / record.revenue) * 100 : 0',
scale: 2,
})
@@ -424,7 +445,7 @@ Field.formula({
Field.formula({
label: 'Weighted Amount',
type: 'currency',
- formula: 'amount * (probability / 100)',
+ formula: 'record.amount * (record.probability / 100)',
scale: 2,
})
@@ -432,7 +453,7 @@ Field.formula({
Field.formula({
label: 'Total with Tax',
type: 'currency',
- formula: 'subtotal * 1.0825', // 8.25% tax
+ formula: 'record.subtotal * 1.0825', // 8.25% tax
scale: 2,
})
```
@@ -444,21 +465,21 @@ Field.formula({
Field.formula({
label: 'Full Name',
type: 'text',
- formula: 'CONCAT(first_name, " ", last_name)',
+ formula: 'record.first_name + " " + record.last_name',
})
-// Email domain
+// Uppercase
Field.formula({
- label: 'Email Domain',
+ label: 'Code',
type: 'text',
- formula: 'SPLIT(email, "@")[1]',
+ formula: 'upper(record.sku)',
})
-// Uppercase
+// Has a company email
Field.formula({
- label: 'Code',
- type: 'text',
- formula: 'UPPER(sku)',
+ label: 'Internal',
+ type: 'boolean',
+ formula: 'endsWith(lower(record.email), "@example.com")',
})
```
@@ -494,20 +515,19 @@ Field.formula({
- Use triggers when a declarative flow is enough
- Mix unrelated concerns in one flow
-### 3. Triggers
+### 3. Hooks
✅ **DO:**
-- Bulkify all trigger logic
-- Use before triggers for field updates
-- Use after triggers for related records
+- Use before hooks to enrich/normalize the incoming record
+- Use after hooks for related-record side effects
+- Use `ctx.api.object('x')` for cross-object access so writes stay in scope
- Handle errors gracefully
-- Add logging for debugging
❌ **DON'T:**
- Query in loops
-- Create in loops
-- Perform complex calculations in triggers
-- Ignore governor limits
+- Trigger unbounded cascades of writes
+- Perform heavy/long-running work inline in a hook
+- Mutate `ctx.result` in before hooks (it is only populated for after hooks)
### 4. Approval Nodes
@@ -552,7 +572,7 @@ validations: [
type: 'script',
severity: 'error',
message: 'Invalid email address',
- condition: 'email != null AND !REGEX(email, "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")',
+ condition: 'record.email != null && !matches(record.email, "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")',
},
],
@@ -563,13 +583,17 @@ flows: [
label: 'Assign Hot Leads',
type: 'record_change',
status: 'active',
- trigger: {
- objectName: 'lead',
- operations: ['create'],
- condition: "record.rating == 'hot' && record.lead_source == 'web'",
- },
nodes: [
- { id: 'start', type: 'start', label: 'Start' },
+ {
+ id: 'start',
+ type: 'start',
+ label: 'Start',
+ config: {
+ triggerType: 'record-after-create',
+ objectName: 'lead',
+ condition: "record.rating == 'hot' && record.lead_source == 'web'",
+ },
+ },
{ id: 'assign_owner', type: 'assignment', label: 'Assign Owner' },
{ id: 'create_task', type: 'create_record', label: 'Create Follow-up Task' },
{ id: 'end', type: 'end', label: 'End' },
@@ -587,10 +611,10 @@ Field.formula({
label: 'Lead Score',
type: 'number',
formula: `
- (IF(rating = "hot", 30, IF(rating = "warm", 20, 10))) +
- (IF(annual_revenue > 1000000, 25, 0)) +
- (IF(number_of_employees > 500, 20, 0)) +
- (IF(industry IN ("technology", "finance"), 15, 0))
+ (record.rating == "hot" ? 30 : record.rating == "warm" ? 20 : 10) +
+ (record.annual_revenue > 1000000 ? 25 : 0) +
+ (record.number_of_employees > 500 ? 20 : 0) +
+ (record.industry in ["technology", "finance"] ? 15 : 0)
`,
})
```
@@ -605,12 +629,10 @@ validations: [
type: 'script',
severity: 'error',
message: 'Cannot skip stages',
+ // Fails when the stage changed to a value not allowed from the previous one.
condition: `
- AND(
- ISCHANGED(stage),
- NOT(ISNEW()),
- NOT(VALID_TRANSITION(PRIORVALUE(stage), stage))
- )
+ record.stage != previous.stage &&
+ !(record.stage in validTransitions[previous.stage])
`,
},
],
@@ -622,13 +644,17 @@ flows: [
label: 'Update Probability',
type: 'record_change',
status: 'active',
- trigger: {
- objectName: 'opportunity',
- operations: ['update'],
- condition: 'record.stage != previous.stage',
- },
nodes: [
- { id: 'start', type: 'start', label: 'Start' },
+ {
+ id: 'start',
+ type: 'start',
+ label: 'Start',
+ config: {
+ triggerType: 'record-after-update',
+ objectName: 'opportunity',
+ condition: 'record.stage != previous.stage',
+ },
+ },
{ id: 'update_probability', type: 'update_record', label: 'Update Probability' },
{ id: 'end', type: 'end', label: 'End' },
],
diff --git a/content/docs/guides/client-sdk.mdx b/content/docs/guides/client-sdk.mdx
index d48fb1f9f4..7d65088a29 100644
--- a/content/docs/guides/client-sdk.mdx
+++ b/content/docs/guides/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
-- **100% Protocol Compliant**: Implements all 15 API namespaces defined in `@objectstack/spec`
+- **Protocol Compliant**: Implements the core API namespaces defined in `@objectstack/spec`, plus additional namespaces (approvals, feed, organizations, projects/environments)
## Installation
@@ -75,7 +75,7 @@ async function main() {
When you call `client.connect()`, the client:
-1. Fetches `/.well-known/objectstack` (or falls back to `/api/v1`)
+1. Probes `/api/v1/discovery` first, then falls back to `{origin}/.well-known/objectstack`
2. Parses the discovery response including `routes`, `features`, and `services`
3. Configures all API route paths dynamically
@@ -105,7 +105,7 @@ if (discovery.services?.auth?.enabled) {
## Protocol Coverage
-The `@objectstack/client` SDK aims to implement the ObjectStack API protocol specification. It covers all 15 API namespaces defined in `@objectstack/spec`:
+The `@objectstack/client` SDK aims to implement the ObjectStack API protocol specification. The core namespaces are listed below; the client also exposes additional namespaces (`approvals`, `feed`, `organizations`, `projects`/environments) — see [`index.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/client/src/index.ts) for the full surface:
| Namespace | Status | Methods | Purpose |
|:----------|:------:|:--------|:--------|
@@ -123,10 +123,10 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
| **i18n** | ✅ | 3 | Internationalization |
| **notifications** | 🟡 | 7 | Legacy notification helpers; receipt/inbox cut-over pending |
| **realtime** | ✅ | 6 | WebSocket subscriptions |
-| **ai** | ✅ | 4 | AI services (NLQ, chat, insights) |
+| **ai** | ✅ | 3 | AI services (NLQ, suggest, insights) |
-**Protocol compliance & verification**: See [`CLIENT_SPEC_COMPLIANCE.md`](https://github.com/objectstack-ai/spec/blob/main/packages/client/CLIENT_SPEC_COMPLIANCE.md) for detailed method-by-method verification and [`CLIENT_SERVER_INTEGRATION_TESTS.md`](https://github.com/objectstack-ai/spec/blob/main/packages/client/CLIENT_SERVER_INTEGRATION_TESTS.md) for comprehensive integration test specifications.
+**Protocol compliance & verification**: See [`CLIENT_SPEC_COMPLIANCE.md`](https://github.com/objectstack-ai/framework/blob/main/packages/client/CLIENT_SPEC_COMPLIANCE.md) for detailed method-by-method verification and [`CLIENT_SERVER_INTEGRATION_TESTS.md`](https://github.com/objectstack-ai/framework/blob/main/packages/client/CLIENT_SERVER_INTEGRATION_TESTS.md) for comprehensive integration test specifications.
---
@@ -136,8 +136,8 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
### `client.meta` — Metadata
```typescript
-// Get object schema
-const accountSchema = await client.meta.getObject('account');
+// Get object schema (getItem('object', name) returns the object definition)
+const accountSchema = await client.meta.getItem('object', 'account');
console.log(accountSchema.fields);
// List all metadata types
@@ -262,8 +262,14 @@ await client.permissions.getEffectivePermissions();
await client.workflow.getConfig('approval');
await client.workflow.getState('approval', recordId);
await client.workflow.transition({ object: 'approval', recordId, transition: 'submit' });
-await client.workflow.approve({ object: 'approval', recordId });
-await client.workflow.reject({ object: 'approval', recordId, reason: 'Incomplete' });
+
+// Approvals — request-based decision API (ADR-0019)
+// Approval is a flow node, not a workflow step: decisions are keyed by request id.
+await client.approvals.listRequests({ status: 'pending' }); // "my approvals" inbox
+await client.approvals.getRequest(requestId);
+await client.approvals.approve(requestId, { comment: 'Looks good' });
+await client.approvals.reject(requestId, { comment: 'Incomplete' });
+await client.approvals.listActions(requestId); // audit trail
// Realtime — WebSocket subscriptions
await client.realtime.connect({ protocol: 'websocket' });
@@ -288,9 +294,10 @@ await client.notifications.markAllRead();
// AI — AI-powered features
await client.ai.nlq({ query: 'Show me all active accounts' });
-await client.ai.chat({ message: 'Summarize this environment', context: environmentId });
await client.ai.suggest({ object: 'account', field: 'industry' });
await client.ai.insights({ object: 'sales', recordId: dealId });
+// Note: conversational chat is no longer on the client — use the Vercel AI SDK
+// (`useChat()` from `@ai-sdk/react`) directly against your chat endpoint.
// i18n — Internationalization
await client.i18n.getLocales();
@@ -435,6 +442,10 @@ interface ClientConfig {
logger?: Logger;
/** Enable debug logging */
debug?: boolean;
+ /** Active project/environment id — injects an `X-Environment-Id` header for multi-tenant routing */
+ environmentId?: string;
+ /** Active UI locale (BCP-47, e.g. `'zh-CN'`) — injects an `Accept-Language` header; sync via `setLocale()` */
+ locale?: string;
}
```
@@ -520,7 +531,7 @@ cd packages/client
pnpm test:integration
```
-Integration tests verify end-to-end communication with a live ObjectStack server across all 15 API namespaces.
+Integration tests verify end-to-end communication with a live ObjectStack server across the client's API namespaces.
**Test coverage**: Integration test specifications cover discovery/connection, authentication, metadata operations, CRUD operations (basic, batch, advanced queries), permissions, workflow, realtime, notifications, AI services, i18n, analytics, packages, views, storage, and automation.
@@ -532,9 +543,9 @@ Integration tests verify end-to-end communication with a live ObjectStack server
For detailed information about the client's protocol implementation:
-- **[Protocol Compliance Matrix](https://github.com/objectstack-ai/spec/blob/main/packages/client/CLIENT_SPEC_COMPLIANCE.md)** — Method-by-method verification of all API methods across 15 namespaces
-- **[Integration Test Specifications](https://github.com/objectstack-ai/spec/blob/main/packages/client/CLIENT_SERVER_INTEGRATION_TESTS.md)** — Comprehensive test cases for client-server communication
-- **[Quick Reference Guide](https://github.com/objectstack-ai/spec/blob/main/packages/client/QUICK_REFERENCE.md)** — Developer navigation and API reference
+- **[Protocol Compliance Matrix](https://github.com/objectstack-ai/framework/blob/main/packages/client/CLIENT_SPEC_COMPLIANCE.md)** — Method-by-method verification of all API methods across 15 namespaces
+- **[Integration Test Specifications](https://github.com/objectstack-ai/framework/blob/main/packages/client/CLIENT_SERVER_INTEGRATION_TESTS.md)** — Comprehensive test cases for client-server communication
+- **[Package README](https://github.com/objectstack-ai/framework/blob/main/packages/client/README.md)** — Developer navigation and API reference
---
diff --git a/content/docs/guides/common-patterns.mdx b/content/docs/guides/common-patterns.mdx
index 9c1cca71e5..61a4b56bc1 100644
--- a/content/docs/guides/common-patterns.mdx
+++ b/content/docs/guides/common-patterns.mdx
@@ -77,7 +77,7 @@ export default defineStack({
## 2. Master-Detail Relationship
-Create a parent-child relationship where child records are cascaded on delete.
+Create a parent-child relationship between a master and its detail records. Note that `master_detail` defaults to `deleteBehavior: 'set_null'`; set `deleteBehavior: 'cascade'` explicitly if you want child records deleted with the parent.
```typescript
{
@@ -113,6 +113,7 @@ Create a parent-child relationship where child records are cascaded on delete.
label: 'Order',
type: 'master_detail',
reference: 'order',
+ deleteBehavior: 'cascade',
inlineEdit: true,
inlineAmountField: 'line_total',
},
@@ -135,26 +136,17 @@ Create a list view with pre-configured filters and column display.
```typescript
import { defineView } from '@objectstack/spec';
-export const taskBoard = defineView({
- name: 'open_tasks',
- label: 'Open Tasks',
- object: 'task',
- type: 'list',
- listType: 'grid',
- columns: [
- { field: 'title', width: 300 },
- { field: 'status', width: 120 },
- { field: 'priority', width: 100 },
- { field: 'assigned_to', width: 150 },
- { field: 'due_date', width: 120 }
- ],
- defaultFilters: {
- status: { $ne: 'closed' }
- },
- defaultSort: [
- { field: 'priority', order: 'desc' },
- { field: 'due_date', order: 'asc' }
- ]
+export const taskViews = defineView({
+ list: {
+ type: 'grid',
+ data: { provider: 'object', object: 'task' },
+ columns: ['title', 'status', 'priority', 'assigned_to', 'due_date'],
+ filter: [['status', '!=', 'closed']],
+ sort: [
+ { field: 'priority', order: 'desc' },
+ { field: 'due_date', order: 'asc' }
+ ]
+ }
});
```
@@ -167,17 +159,15 @@ Display records as a kanban board grouped by a status field.
```typescript
import { defineView } from '@objectstack/spec';
-export const kanban = defineView({
- name: 'task_board',
- label: 'Task Board',
- object: 'task',
- type: 'list',
- listType: 'kanban',
- kanbanConfig: {
- groupByField: 'status',
- cardTitle: 'title',
- cardSubtitle: 'assigned_to',
- showCount: true
+export const taskBoard = defineView({
+ list: {
+ type: 'kanban',
+ data: { provider: 'object', object: 'task' },
+ columns: ['title', 'assigned_to'],
+ kanban: {
+ groupByField: 'status',
+ columns: ['title', 'assigned_to']
+ }
}
});
```
@@ -197,26 +187,30 @@ export const crm = defineApp({
description: 'Customer Relationship Management',
navigation: [
{
+ id: 'nav_contacts',
type: 'object',
- object: 'contact',
+ objectName: 'contact',
label: 'Contacts',
icon: 'users'
},
{
+ id: 'nav_deals',
type: 'object',
- object: 'deal',
+ objectName: 'deal',
label: 'Deals',
icon: 'dollar-sign'
},
{
+ id: 'nav_activities',
type: 'object',
- object: 'activity',
+ objectName: 'activity',
label: 'Activities',
icon: 'activity'
},
{
+ id: 'nav_dashboard',
type: 'dashboard',
- dashboard: 'sales_dashboard',
+ dashboardName: 'sales_dashboard',
label: 'Dashboard',
icon: 'bar-chart'
}
@@ -236,27 +230,26 @@ import { defineFlow } from '@objectstack/spec';
export const assignmentNotification = defineFlow({
name: 'task_assignment_notification',
label: 'Task Assignment Notification',
- type: 'autolaunched',
- trigger: {
- type: 'record_change',
- object: 'task',
- event: 'after_update',
- conditions: {
- field: 'assigned_to',
- changed: true
- }
- },
- steps: [
+ type: 'record_change',
+ nodes: [
+ { id: 'start', type: 'start', label: 'Start' },
{
- type: 'action',
- name: 'send_notification',
- action: 'send_email',
+ id: 'notify',
+ type: 'notify',
+ label: 'Notify Assignee',
config: {
- to: '{{record.assigned_to.email}}',
- subject: 'Task Assigned: {{record.title}}',
- body: 'You have been assigned to task "{{record.title}}".'
+ // String fields use single-brace {…} templates, not {{…}}
+ to: '{record.assigned_to.email}',
+ subject: 'Task Assigned: {record.title}',
+ body: 'You have been assigned to task "{record.title}".'
}
- }
+ },
+ { id: 'end', type: 'end', label: 'End' }
+ ],
+ edges: [
+ // Edge conditions are bare CEL (ADR-0032) — no {…} braces.
+ { id: 'e1', source: 'start', target: 'notify', condition: 'record.assigned_to != previous.assigned_to' },
+ { id: 'e2', source: 'notify', target: 'end' }
]
});
```
@@ -292,21 +285,22 @@ Define a multi-step approval flow for records.
flows: [{
name: 'expense_approval',
label: 'Expense Approval',
- type: 'autolaunched',
- trigger: {
- type: 'record_change',
- object: 'expense_report',
- event: 'after_update'
- },
- steps: [
- {
- type: 'decision',
- name: 'check_amount',
- conditions: [
- { name: 'auto_approve', condition: '{{record.amount}} < 100' },
- { name: 'needs_approval', condition: '{{record.amount}} >= 100' }
- ]
- }
+ type: 'record_change',
+ nodes: [
+ { id: 'start', type: 'start', label: 'Start' },
+ { id: 'check_amount', type: 'decision', label: 'Check Amount' },
+ { id: 'auto_approve', type: 'update_record', label: 'Auto Approve' },
+ { id: 'request_approval', type: 'notify', label: 'Request Approval' },
+ { id: 'end', type: 'end', label: 'End' }
+ ],
+ edges: [
+ { id: 'e1', source: 'start', target: 'check_amount' },
+ // Branch conditions are bare CEL (ADR-0032) — reference fields directly,
+ // no {…} template braces.
+ { id: 'e2', source: 'check_amount', target: 'auto_approve', condition: 'record.amount < 100' },
+ { id: 'e3', source: 'check_amount', target: 'request_approval', condition: 'record.amount >= 100' },
+ { id: 'e4', source: 'auto_approve', target: 'end' },
+ { id: 'e5', source: 'request_approval', target: 'end' }
]
}]
}
@@ -324,30 +318,33 @@ import { defineAgent } from '@objectstack/spec';
export const supportAgent = defineAgent({
name: 'support_agent',
label: 'Customer Support Agent',
- role: 'assistant',
- model: 'gpt-4o',
+ role: 'Customer Support Agent',
+ model: {
+ provider: 'openai',
+ model: 'gpt-4o',
+ temperature: 0.3,
+ maxTokens: 2048
+ },
instructions: `You are a helpful customer support agent.
You can search for customer records, look up order status,
and create support tickets. Always be polite and professional.`,
tools: [
{
- type: 'object_query',
- object: 'contact',
+ type: 'query',
+ name: 'contact',
description: 'Search customer records'
},
{
- type: 'object_query',
- object: 'order',
+ type: 'query',
+ name: 'order',
description: 'Look up order status'
},
{
- type: 'object_create',
- object: 'support_ticket',
+ type: 'action',
+ name: 'create_support_ticket',
description: 'Create a support ticket'
}
- ],
- temperature: 0.3,
- maxTokens: 2048
+ ]
});
```
@@ -379,25 +376,18 @@ const page3 = { ...page1, offset: 50 };
### Cursor-Based (Scalable)
```typescript
-// First page
+// First page — no cursor yet
const firstPage = {
object: 'activity',
fields: ['id', 'type', 'description', 'created_at'],
orderBy: [{ field: 'created_at', order: 'desc' }],
- limit: 50,
- keyset: {
- field: 'id',
- order: 'desc'
- }
+ limit: 50
};
-// Next page (using cursor from previous response)
+// Next page — pass the opaque cursor token returned with the previous response
const nextPage = {
...firstPage,
- keyset: {
- ...firstPage.keyset,
- after: 'last_id_from_previous_page'
- }
+ cursor: { /* opaque cursor token from the previous response */ }
};
```
@@ -420,21 +410,30 @@ Restrict field visibility and editability based on user profiles.
salary: { label: 'Salary', type: 'currency',
hidden: true, // Hidden from default views
encryptionConfig: {
+ enabled: true,
algorithm: 'aes-256-gcm',
- keyRotation: true
+ scope: 'field',
+ keyManagement: {
+ provider: 'local',
+ rotationPolicy: { enabled: true, frequencyDays: 90 }
+ }
}
},
ssn: { label: 'SSN', type: 'text',
hidden: true,
maskingRule: {
+ field: 'ssn',
strategy: 'partial',
- visibleChars: 4,
- maskChar: '*',
- position: 'end'
+ pattern: '\\d{4}$'
},
encryptionConfig: {
+ enabled: true,
algorithm: 'aes-256-gcm',
- keyRotation: true
+ scope: 'field',
+ keyManagement: {
+ provider: 'local',
+ rotationPolicy: { enabled: true, frequencyDays: 90 }
+ }
}
},
}
diff --git a/content/docs/guides/data-modeling.mdx b/content/docs/guides/data-modeling.mdx
index e271749a1f..d403351099 100644
--- a/content/docs/guides/data-modeling.mdx
+++ b/content/docs/guides/data-modeling.mdx
@@ -38,7 +38,7 @@ export const MyObject = ObjectSchema.create({
description: 'Description...', // Help text
// Display configuration
- titleFormat: '{field1} - {field2}',
+ titleFormat: '{{record.field1}} - {{record.field2}}',
compactLayout: ['field1', 'field2', 'field3'],
// Fields definition
@@ -54,7 +54,6 @@ export const MyObject = ObjectSchema.create({
// Business rules
validations: [...],
- workflows: [...],
});
```
@@ -67,7 +66,7 @@ export const MyObject = ObjectSchema.create({
| `pluralLabel` | string | Plural display name | `'Accounts'` |
| `icon` | string | Icon identifier | `'building'` |
| `description` | string | Help text | `'Companies...'` |
-| `titleFormat` | string | Record title template | `'{name} - {id}'` |
+| `titleFormat` | string | Record title template (`{{record.field}}` interpolation) | `'{{record.name}} - {{record.id}}'` |
| `compactLayout` | string[] | Quick view fields | `['name', 'status']` |
### Enable Features
@@ -158,20 +157,20 @@ Field.percent({
Field.date({
label: 'Close Date',
required: true,
- defaultValue: 'TODAY()',
+ // Dynamic defaults must be a CEL expression envelope; a bare string
+ // like 'TODAY()' would be stored verbatim.
+ defaultValue: { dialect: 'cel', source: 'today()' },
})
// Date and time
Field.datetime({
label: 'Last Modified',
readonly: true,
- defaultValue: 'NOW()',
+ defaultValue: { dialect: 'cel', source: 'now()' },
})
-// Time only
-Field.time({
- label: 'Business Hours Start',
-})
+// Time only (no `Field.time` helper — use the generic form)
+{ type: 'time', label: 'Business Hours Start' }
```
### Boolean Field
@@ -219,9 +218,9 @@ Field.autonumber({
format: 'ACC-{0000}', // ACC-0001, ACC-0002, ...
})
-// Other formats:
-// 'INV-{YYYY}-{0000}' // INV-2024-0001
-// '{YYYY}{MM}{DD}-{000}' // 20240115-001
+// The format string supports a single zero-pad token, e.g.:
+// 'INV-{000}' // INV-001, INV-002, ...
+// Date tokens like {YYYY}/{MM}/{DD} are not interpolated.
```
### Lookup Fields
@@ -235,13 +234,10 @@ Field.lookup('account', {
required: true,
})
-// Lookup with filters
+// Lookup with filters (array of filter strings)
Field.lookup('contact', {
label: 'Primary Contact',
- referenceFilters: {
- account: '{account}', // Same account only
- is_active: true,
- }
+ referenceFilters: ['account = {account}', 'is_active = true'],
})
```
@@ -345,10 +341,7 @@ Reference records that meet criteria:
```typescript
contact: Field.lookup('contact', {
label: 'Contact',
- referenceFilters: {
- account: '{account}', // Filter by parent account
- is_active: true,
- }
+ referenceFilters: ['account = {account}', 'is_active = true'],
})
```
@@ -377,7 +370,9 @@ Child records automatically appear in related lists when a lookup points to the
### Script Validations
-Custom JavaScript-like expressions:
+`condition` is a CEL predicate that runs against the record. **When the
+predicate evaluates to `TRUE`, validation fails** and the configured message is
+raised. Reference fields as `record.`.
```typescript
validations: [
@@ -386,31 +381,31 @@ validations: [
type: 'script',
severity: 'error',
message: 'Annual Revenue must be positive',
- condition: 'annual_revenue < 0',
+ condition: 'record.annual_revenue < 0',
},
{
name: 'close_date_future',
type: 'script',
severity: 'warning',
message: 'Close Date should be in the future',
- condition: 'close_date < TODAY()',
+ condition: 'record.close_date < today()',
},
]
```
-### Unique Validations
+### Unique Constraints
-Ensure field values are unique:
+There is no `unique` validation type. Enforce uniqueness with a unique index or
+a field-level `unique` flag:
```typescript
-{
- name: 'email_unique',
- type: 'unique',
- severity: 'error',
- message: 'Email must be unique',
- fields: ['email'],
- caseSensitive: false,
-}
+// Unique index
+indexes: [
+ { fields: ['email'], unique: true },
+]
+
+// or field-level
+Field.email({ label: 'Email', unique: true })
```
### Required Field Validations
@@ -436,50 +431,51 @@ Field.text({
Calculate values automatically:
+Formula expressions are written in **CEL** and reference fields as
+`record.`. The calculation goes in the `expression` property (not
+`formula`); the field type is already `formula`, so don't pass `type`.
+
```typescript
// Simple calculation
Field.formula({
label: 'Total Price',
- type: 'currency',
- formula: 'subtotal - discount + tax',
+ expression: 'record.subtotal - record.discount + record.tax',
scale: 2,
})
-// Conditional logic
+// Conditional logic (CEL ternary)
Field.formula({
label: 'Priority Level',
- type: 'text',
- formula: 'IF(amount > 100000, "High", IF(amount > 50000, "Medium", "Low"))',
+ expression: 'record.amount > 100000 ? "High" : (record.amount > 50000 ? "Medium" : "Low")',
})
// Date calculation
Field.formula({
label: 'Days to Close',
- type: 'number',
- formula: 'DAYS_DIFF(close_date, TODAY())',
+ expression: 'record.close_date < today() ? 1 : 0',
})
// Percentage calculation
Field.formula({
label: 'Response Rate',
- type: 'percent',
- formula: 'IF(num_sent > 0, (num_responses / num_sent) * 100, 0)',
+ expression: 'record.num_sent > 0 ? (record.num_responses / record.num_sent) * 100 : 0',
scale: 2,
})
```
-### Common Formula Functions
-
-| Function | Description | Example |
-|----------|-------------|---------|
-| `IF(condition, true_value, false_value)` | Conditional | `IF(amount > 1000, "High", "Low")` |
-| `AND(expr1, expr2, ...)` | Logical AND | `AND(is_active, amount > 0)` |
-| `OR(expr1, expr2, ...)` | Logical OR | `OR(status = "new", status = "pending")` |
-| `NOT(expr)` | Logical NOT | `NOT(is_deleted)` |
-| `ISBLANK(field)` | Check if blank | `ISBLANK(phone)` |
-| `TODAY()` | Current date | `TODAY()` |
-| `NOW()` | Current datetime | `NOW()` |
-| `DAYS_DIFF(date1, date2)` | Days between dates | `DAYS_DIFF(end_date, start_date)` |
+### Common CEL Operators & Functions
+
+| Construct | Description | Example |
+|-----------|-------------|---------|
+| `cond ? a : b` | Conditional (ternary) | `record.amount > 1000 ? "High" : "Low"` |
+| `&&` | Logical AND | `record.is_active && record.amount > 0` |
+| `\|\|` | Logical OR | `record.status == "new" \|\| record.status == "pending"` |
+| `!` | Logical NOT | `!record.is_deleted` |
+| `isBlank(value)` | Check if blank | `isBlank(record.phone)` |
+| `coalesce(a, b)` | First non-null value | `coalesce(record.nickname, record.name)` |
+| `today()` | Current date | `today()` |
+| `now()` | Current datetime | `now()` |
+| `daysFromNow(n)` / `daysAgo(n)` | Date offset | `daysFromNow(30)` |
---
diff --git a/content/docs/guides/deployment-vercel.mdx b/content/docs/guides/deployment-vercel.mdx
index 003b04b77b..5ace9d4fa4 100644
--- a/content/docs/guides/deployment-vercel.mdx
+++ b/content/docs/guides/deployment-vercel.mdx
@@ -5,7 +5,7 @@ description: Deploy ObjectStack applications to Vercel — Server mode (recommen
# Deploy to Vercel
-ObjectStack supports two deployment modes on Vercel. **Server mode is recommended** for production and is the default for ObjectStack Studio.
+ObjectStack supports two deployment modes on Vercel. **Server mode is recommended** for production. The published ObjectStack Studio/console ships as a static Vite SPA that points at a separate ObjectStack server (via `VITE_SERVER_URL`) — see the note under Option B.
| Mode | Runtime | Vercel Feature | Use Case |
| :--- | :--- | :--- | :--- |
@@ -142,7 +142,7 @@ export default defineConfig({
"outputDirectory": "dist",
"build": {
"env": {
- "VITE_RUNTIME_MODE": "msw"
+ "VITE_USE_MOCK_SERVER": "true"
}
},
"rewrites": [
@@ -168,7 +168,7 @@ If your project lives in a monorepo (e.g. pnpm workspaces + Turborepo), update t
"outputDirectory": "dist",
"build": {
"env": {
- "VITE_RUNTIME_MODE": "msw"
+ "VITE_USE_MOCK_SERVER": "true"
}
},
"rewrites": [
@@ -266,7 +266,11 @@ export { handler as GET, handler as POST, handler as PATCH, handler as DELETE };
### Option B: Hono + `@objectstack/hono` (Vite SPA)
-This is what ObjectStack Studio uses. The Vite SPA is served as static assets, and a Hono-based serverless function handles `/api/*` requests.
+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.
+
**1. Create the kernel singleton** (`api/_kernel.ts` — prefixed with `_` to prevent Vercel from creating a route):
@@ -328,7 +332,7 @@ export default handle(app);
"outputDirectory": "dist",
"build": {
"env": {
- "VITE_RUNTIME_MODE": "server",
+ "VITE_USE_MOCK_SERVER": "false",
"VITE_SERVER_URL": ""
}
},
@@ -353,7 +357,7 @@ Configure these in Vercel Project Settings → Environment Variables:
| Variable | Description |
| :--- | :--- |
-| `VITE_RUNTIME_MODE` | `msw` (in-browser) or `server` (real backend) |
+| `VITE_USE_MOCK_SERVER` | `true` = in-browser MSW kernel; `false` = real backend. A **build-time** flag baked into the SPA bundle. |
| `VITE_SERVER_URL` | Backend API URL (empty for same-origin) |
### Cloud control plane
@@ -366,7 +370,7 @@ storage for artifacts.
| Variable | Required | Description |
| :--- | :--- | :--- |
-| `AUTH_SECRET` | yes | ≥ 32 chars |
+| `OS_AUTH_SECRET` | yes | ≥ 32 chars (`AUTH_SECRET` is accepted as a deprecated alias) |
| `OS_CONTROL_DATABASE_URL` | yes | `libsql://…` (Turso) — control plane DB |
| `OS_CONTROL_DATABASE_AUTH_TOKEN` | yes | Turso token (or `TURSO_AUTH_TOKEN`) |
| `OS_STORAGE_ADAPTER` | yes | `s3` (must, on Vercel) |
@@ -387,20 +391,14 @@ storage for artifacts.
---
-## Runtime Mode Switching
+## Choosing the Mode
-ObjectStack Studio supports switching between MSW and Server mode at runtime using a URL parameter:
+The mode is selected at **build time** via the `VITE_USE_MOCK_SERVER` flag, which is baked into the SPA bundle:
-```
-https://myapp.vercel.app?mode=msw → In-browser kernel
-https://myapp.vercel.app?mode=server → Real backend
-```
+- `VITE_USE_MOCK_SERVER=true` → the in-browser MSW kernel (static demo).
+- `VITE_USE_MOCK_SERVER=false` → the SPA talks to a real backend at `VITE_SERVER_URL` (empty = same-origin).
-This is controlled by the config module which checks (in priority order):
-1. `?mode=` URL parameter
-2. `VITE_RUNTIME_MODE` environment variable
-3. Embedded detection (`/_studio/` path)
-4. Default: `msw`
+Because the flag is compiled into the bundle, there is no runtime `?mode=` URL switch — to change modes you must rebuild with the desired value.
---
@@ -409,8 +407,8 @@ This is controlled by the config module which checks (in priority order):
### Server Mode (Recommended)
- [ ] `api/index.ts` Hono entrypoint exists with `handle(app)` export
-- [ ] `api/_kernel.ts` boots the kernel with the correct driver and broker shim
-- [ ] `vercel.json` sets `VITE_RUNTIME_MODE=server` and `VITE_SERVER_URL=` (empty)
+- [ ] `api/_kernel.ts` boots the kernel with the correct driver
+- [ ] `vercel.json` sets `VITE_USE_MOCK_SERVER=false` and `VITE_SERVER_URL=` (empty)
- [ ] Rewrite rule routes `/api/*` to `/api` and excludes `/api/` from SPA rewrite
- [ ] `DATABASE_URL` is configured in Vercel environment variables (for production drivers)
- [ ] CORS is configured if frontend and API are on different origins
@@ -419,7 +417,7 @@ This is controlled by the config module which checks (in priority order):
- [ ] `msw init public --save` has been run (Service Worker in `public/`)
- [ ] `vercel.json` specifies `"framework": "vite"` and SPA rewrites
-- [ ] `VITE_RUNTIME_MODE=msw` is set in build environment
+- [ ] `VITE_USE_MOCK_SERVER=true` is set in build environment
- [ ] Seed data is defined in `objectstack.config.ts` (`data` array)
---
diff --git a/content/docs/guides/formula.mdx b/content/docs/guides/formula.mdx
index 45be7ea83a..51963933f9 100644
--- a/content/docs/guides/formula.mdx
+++ b/content/docs/guides/formula.mdx
@@ -11,7 +11,7 @@ piece of metadata needs to compute a value or evaluate a condition:
- **Formula fields** (`type: 'formula'`)
- **Predicates** — validation `condition`, sharing `condition`, field
conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`),
- conditional visibility (`visibleOn`), action `disabled`, view filter
+ view section/column visibility (`visibleOn`), action `disabled`, view filter
`criteria`, hook `condition`, flow decisions
- **Dynamic seed values** — fixtures whose value depends on the install-time
clock or identity context
@@ -89,8 +89,8 @@ export const Invoice = ObjectSchema.create({
titleFormat: tmpl`Invoice {{record.invoice_no}} – {{record.customer.name}}`,
fields: {
total: Field.formula({
- result_type: 'currency',
- formula: F`record.subtotal + record.subtotal * record.tax_rate`,
+ type: 'currency',
+ expression: F`record.subtotal + record.subtotal * record.tax_rate`,
}),
po_number: Field.text({
requiredWhen: P`record.amount > 10000`,
@@ -142,10 +142,12 @@ All functions are pure given a pinned `now`, which is what makes
|:---|:---|:---|
| `now()` | timestamp | Pinned wall-clock at evaluation start |
| `today()` | timestamp | `now()` truncated to UTC start-of-day |
-| `daysFromNow(n)` | timestamp | `today() + n` days |
-| `daysAgo(n)` | timestamp | `today() - n` days |
+| `daysFromNow(n)` | timestamp | `now() + n` days (preserves wall-clock time, not start-of-day) |
+| `daysAgo(n)` | timestamp | `now() - n` days |
| `isBlank(v)` | bool | True for `null`, `undefined`, `''`, `[]` |
| `coalesce(v, fallback)` | dyn | `v` when non-null, else `fallback` |
+| `trim(v)` | string | `v` with leading/trailing whitespace removed |
+| `joinNonEmpty(list, sep)` | string | `list` joined by `sep`, skipping blank entries |
Add new helpers in
[`packages/formula/src/stdlib.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/formula/src/stdlib.ts).
@@ -162,7 +164,7 @@ Keep them pure, dependency-free, and AI-readable.
| `input` | hook payload | hooks |
| `os.user` | install / request user | seed, predicates with identity |
| `os.org` | active organization | seed, predicates |
-| `os.env` | install env (`production`, `staging`, …) | seed, predicates |
+| `os.env` | install env (`prod`, `dev`, `test`) | seed, predicates |
---
@@ -174,15 +176,13 @@ Keep them pure, dependency-free, and AI-readable.
{
name: 'full_name',
type: 'formula',
- formula: F`coalesce(record.salutation, '') + ' '
- + coalesce(record.first_name, '') + ' '
- + coalesce(record.last_name, '')`,
- result_type: 'text',
+ expression: F`joinNonEmpty([record.salutation, record.first_name, record.last_name], ' ')`,
}
```
-CEL throws on `null + string`, so wrap every nullable operand in
-`coalesce(..., '')`.
+`joinNonEmpty` skips blank parts, so you avoid the verbose triple-coalesce.
+Building the string by hand with `+` would also work, but CEL throws on
+`null + string`, so each nullable operand must be wrapped in `coalesce(..., '')`.
### Conditional formula
@@ -190,10 +190,9 @@ CEL throws on `null + string`, so wrap every nullable operand in
{
name: 'roi',
type: 'formula',
- formula: F`coalesce(record.actual_cost, 0) > 0
+ expression: F`coalesce(record.actual_cost, 0) > 0
? ((coalesce(record.actual_revenue, 0) - record.actual_cost) * 100.0) / record.actual_cost
: 0.0`,
- result_type: 'percent',
}
```
@@ -203,7 +202,7 @@ CEL throws on `null + string`, so wrap every nullable operand in
{
name: 'rating',
type: 'picklist',
- visibleOn: P`record.status == 'qualified'`,
+ visibleWhen: P`record.status == 'qualified'`,
}
Field.text({
@@ -246,20 +245,25 @@ clock at install time.
```ts
{
name: 'amount_positive',
- type: 'expression',
- condition: P`record.amount > 0`,
- errorMessage: 'Amount must be positive.',
+ type: 'script',
+ condition: P`record.amount <= 0`,
+ message: 'Amount must be positive.',
}
```
+The generic CEL rule is `type: 'script'`. For `script` rules, the `condition`
+is the **failure** predicate — when it evaluates TRUE the validation fails, so
+invert the test (here `amount <= 0` rejects non-positive amounts).
+
### Hook condition
```ts
{
name: 'notify_on_escalation',
- on: 'after_update',
+ object: 'case',
+ events: ['afterUpdate'],
condition: P`previous.status != 'escalated' && record.status == 'escalated'`,
- handler: 'notifyOpsTeam',
+ body: { language: 'js', source: 'await ctx.notifyOpsTeam(record)' },
}
```
@@ -320,8 +324,9 @@ The low-level engine never throws — `evaluate()` returns `{ ok: false, error }
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 `validate_expression` agent tool
-runs this same validator so you can check an expression before saving.
+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.
---
diff --git a/content/docs/guides/hook-bodies.mdx b/content/docs/guides/hook-bodies.mdx
index ef9a34474a..27f3c75342 100644
--- a/content/docs/guides/hook-bodies.mdx
+++ b/content/docs/guides/hook-bodies.mdx
@@ -15,16 +15,20 @@ A third "compiled module" form (L3) was considered and explicitly **disabled**
## TL;DR
```ts
-// Authoring (TS source — packages/myapp/src/objects/account.hook.ts)
-export const beforeInsert = defineHook({
- name: 'normalize_account',
- object: 'account',
- events: ['beforeInsert'],
- handler: async (ctx) => {
- if (ctx.input.website) {
- ctx.input.website = ctx.input.website.toLowerCase();
- }
- },
+// Authoring (TS source — packages/myapp/objectstack.config.ts)
+export default defineStack({
+ hooks: [
+ {
+ name: 'normalize_account',
+ object: 'account',
+ events: ['beforeInsert'],
+ handler: async (ctx) => {
+ if (ctx.input.website) {
+ ctx.input.website = ctx.input.website.toLowerCase();
+ }
+ },
+ },
+ ],
});
```
@@ -42,7 +46,7 @@ export const beforeInsert = defineHook({
}
```
-The CLI builder extracts the function body, AST-checks it, and emits the metadata above. No `runtimeModule`, no `bundle.functions[normalize_account]` — the artifact is self-contained.
+The CLI builder stringifies the inline handler, runs a regex allow-list over the source, and emits the metadata above. No `runtimeModule`, no `bundle.functions[normalize_account]` — the artifact is self-contained.
## Why metadata-only?
@@ -97,7 +101,7 @@ The script sees only what the surrounding `ctx` object exposes:
| `ctx.crypto.randomUUID()` | UUID generation. | `crypto.uuid` |
| `ctx.crypto.hash(algo, data)` | Sha-256/512 etc. | `crypto.hash` |
| `ctx.log.{info,warn,error}` | Structured logging. | `log` |
-| `ctx.connector(name).(...)` | Outbound HTTP / SaaS calls. | (separate Connector spec) |
+| `ctx.connector(name).(...)` _(planned)_ | Outbound HTTP / SaaS calls. **Not yet wired into the sandbox** — ships with the separate Connector spec. | (separate Connector spec) |
### What the sandbox forbids
@@ -138,15 +142,15 @@ If you have a body that genuinely cannot be expressed in L1+L2 (typically: it ne
## Build pipeline
-`objectstack build` walks `*.hook.ts` and `*.action.ts` in your package:
+`objectstack build` (an alias for `objectstack compile`) loads your `defineStack({...})` config and lowers every inline hook/action handler. It does **not** glob `*.hook.ts` / `*.action.ts` source files off disk — the body source comes from the live function objects in the loaded config:
-1. Parse with the TypeScript compiler API.
-2. Find each handler arrow / function expression.
-3. AST allow-list check the body (see "What the sandbox forbids" above).
-4. **Pass:** emit `body: { language: 'js', source: , capabilities: }`.
-5. **Fail:** the build aborts with a precise diagnostic — for example: `account.hook.ts:42 — fetch() is not allowed in hook bodies. Define a Connector recipe instead.`
+1. Load the `defineStack` config and normalise its shape.
+2. For each inline handler, take its source via `String(fn)` (the callable is already loaded by tsx/esbuild).
+3. Run a regex allow-list over the stringified body (see "What the sandbox forbids" above).
+4. **Pass:** emit `body: { language: 'js', source: , capabilities: }`.
+5. **Forbidden token (default):** extraction fails, a `bodyExtractionWarning` is recorded, and the callable still ships via the back-compat handler-ref bundle — the build does **not** abort. Pass `objectstack compile --strict-body` to turn extraction warnings into a hard build failure (exit 1) with a per-callable diagnostic, e.g. `hook 'normalize_account': fetch() is not allowed in hook bodies — declare a Connector recipe instead.`
-Capabilities are inferred from the AST (e.g. `ctx.api.object(...).insert(...)` ⇒ `api.write`). You can override with a directive comment when the inference is wrong.
+Capabilities are inferred by matching known patterns in the body source (e.g. `ctx.api.object(...).insert(...)` ⇒ `api.write`). A fuller AST-based analysis is planned for a later version. You can override with a directive comment when the inference is wrong.
## Migration
diff --git a/content/docs/guides/packages.mdx b/content/docs/guides/packages.mdx
index e6c887a5f9..9d2031e662 100644
--- a/content/docs/guides/packages.mdx
+++ b/content/docs/guides/packages.mdx
@@ -26,12 +26,14 @@ ObjectStack is organized into **70 package manifests** across multiple categorie
**The Constitution** — Protocol schemas, types, and constants for the entire ObjectStack ecosystem.
- **Purpose**: Zod-first schema definitions for all 15 protocol domains
-- **Exports**: Data, UI, System, Automation, AI, API, Identity, Security, Kernel, Cloud, QA, Contracts, Integration, Studio, Shared namespaces, plus builder functions (`defineStack`, `defineView`, `defineApp`, `defineFlow`, `defineAgent`, `defineTool`, `defineSkill`) and `ObjectSchema.create()` for objects.
+- **Exports**: Builder functions (`defineStack`, `defineView`, `defineApp`, `defineFlow`, `defineAgent`, `defineTool`, `defineSkill`) plus `ObjectSchema.create()` for objects. Protocol namespaces (Data, UI, System, Automation, AI, API, Identity, Security, Kernel, Cloud, QA, Contracts, Integration, Studio, Shared) are not re-exported from the top-level entry for tree-shaking reasons — import them from subpaths such as `@objectstack/spec/data` and `@objectstack/spec/ui`.
- **When to use**: Import types, schemas, and builder functions when authoring metadata.
- **Documentation**: [Protocol Reference](/docs/references)
```typescript
-import { Data, UI, defineStack, defineView } from '@objectstack/spec';
+import { defineStack, defineView } from '@objectstack/spec';
+import * as Data from '@objectstack/spec/data';
+import * as UI from '@objectstack/spec/ui';
import { ObjectSchema, Field } from '@objectstack/spec/data';
```
@@ -99,7 +101,6 @@ const kernel = new ObjectKernel();
- **Purpose**: Standard system tables and their metadata, so apps don't redefine identity, audit, or approvals
- **When to use**: Always — bundled into the runtime
-- **README**: [View README](/packages/platform-objects/README.md)
---
@@ -116,7 +117,7 @@ const kernel = new ObjectKernel();
```typescript
import { ObjectStackClient } from '@objectstack/client';
-const client = new ObjectStackClient({ baseURL: 'https://api.example.com' });
+const client = new ObjectStackClient({ baseUrl: 'https://api.example.com' });
```
### @objectstack/client-react
@@ -124,7 +125,7 @@ const client = new ObjectStackClient({ baseURL: 'https://api.example.com' });
**React Hooks & Bindings** — React hooks for ObjectStack.
- **Purpose**: React hooks for queries, mutations, real-time subscriptions
-- **Exports**: `useQuery`, `useMutation`, `useRealtime`, `useAuth`, etc.
+- **Exports**: `useQuery`, `useMutation`, `useRealtimeConnection`, `useView`, `useObject`, `useMetadata`, etc.
- **When to use**: React applications
- **README**: [View README](/packages/client-react/README.md)
@@ -165,21 +166,18 @@ const driver = new SqlDriver({
});
```
-### @objectstack/driver-turso
+### @objectstack/driver-sqlite-wasm
-**Turso/libSQL Driver** — Edge-first SQLite with embedded replicas.
+**WASM SQLite Driver** — Edge/browser-friendly SQLite via sql.js (WebAssembly).
-- **Purpose**: Edge-native SQLite with local-first architecture and multi-tenancy
-- **Modes**: Remote (edge), Embedded Replica (local-first), Local (dev)
-- **When to use**: Vercel Edge, Cloudflare Workers, global low-latency deployments
-- **README**: [View README](/packages/plugins/driver-turso/README.md)
+- **Purpose**: SQLite running entirely in WebAssembly (no native bindings), with optional `fs`-backed persistence
+- **Modes**: In-memory (`:memory:`) or a file path persisted via the `persist` option
+- **When to use**: Environments without native SQLite, edge/browser runtimes, lightweight local-first storage
+- **README**: [View README](/packages/plugins/driver-sqlite-wasm/README.md)
```typescript
-import { TursoDriver } from '@objectstack/driver-turso';
-const driver = new TursoDriver({
- url: 'libsql://mydb-username.turso.io',
- authToken: process.env.TURSO_AUTH_TOKEN,
-});
+import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
+const driver = new SqliteWasmDriver({ filename: ':memory:' });
```
### @objectstack/driver-mongodb
@@ -319,7 +317,7 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern
**Organization Scoping Plugin** — Multi-org (a.k.a. "soft" multi-tenant) row-level scoping built on top of `plugin-security`.
- **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_TENANT=true` (registered automatically before `plugin-security`)
+- **When to use**: Multi-organization SaaS where every row is scoped to an `sys_organization`. Enable by setting `OS_MULTI_ORG_ENABLED=true` (the legacy `OS_MULTI_TENANT` still works as a deprecated alias); registered automatically before `plugin-security`
- **README**: [View README](/packages/plugins/plugin-org-scoping/README.md)
### @objectstack/plugin-audit
@@ -368,7 +366,6 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern
- **Features**: Approver resolution (user/role/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`
-- **README**: [View README](/packages/plugins/plugin-approvals/README.md)
### @objectstack/plugin-sharing
@@ -376,7 +373,6 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern
- **Features**: Manual shares, sharing rules, team-based access, `sys_record_share`
- **When to use**: Teams that need to grant per-record access beyond RBAC
-- **README**: [View README](/packages/plugins/plugin-sharing/README.md)
### @objectstack/plugin-email
@@ -384,7 +380,6 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern
- **Features**: Provider adapters, MJML templates, delivery tracking
- **When to use**: Transactional and workflow-driven email
-- **README**: [View README](/packages/plugins/plugin-email/README.md)
### @objectstack/plugin-webhooks
@@ -392,7 +387,6 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern
- **Features**: Subscriptions, retries, signed payloads, delivery logs
- **When to use**: Integrating ObjectStack events with external systems
-- **README**: [View README](/packages/plugins/plugin-webhooks/README.md)
### @objectstack/plugin-reports
@@ -400,7 +394,6 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern
- **Features**: Tabular/aggregate reports, scheduled delivery, exports
- **When to use**: Operational reporting on top of business objects
-- **README**: [View README](/packages/plugins/plugin-reports/README.md)
---
@@ -465,19 +458,19 @@ ObjectStack integrates with popular web frameworks via adapters. All adapters ex
**CLI Tool** — Command-line interface for ObjectStack.
-- **Commands**: `serve`, `studio`, `doctor`, `migrate`, `deploy`
+- **Commands**: `serve`, `dev`, `start`, `doctor`, `compile`, `build`, `validate`, `generate`, `package`, `meta`, … (binary is `os` / `objectstack`)
- **When to use**: Development, deployment, project management
- **README**: [View README](/packages/cli/README.md)
```bash
-npx @objectstack/cli serve --dev
+npx os serve --dev
```
### @objectstack/create-objectstack
**Project Scaffolding** — Create new ObjectStack projects.
-- **Templates**: Minimal, CRM, Todo, E-commerce
+- **Templates**: `blank` (default, bundled), plus remote templates `todo`, `compliance`, `content`, `contracts`, `procurement`
- **When to use**: Start a new ObjectStack project
- **README**: [View README](/packages/create-objectstack/README.md)
@@ -521,11 +514,11 @@ The snippets below illustrate which **runtime** packages you typically reach for
```typescript
// Host bootstrap (pseudocode — exact shape depends on adapter)
import { ObjectKernel } from '@objectstack/core';
-import { createDriverPlugin } from '@objectstack/driver-turso';
+import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
import { createServiceAiPlugin } from '@objectstack/service-ai';
const kernel = new ObjectKernel();
-await kernel.use(createDriverPlugin({ /* libSQL config */ }));
+await kernel.use(new SqliteWasmDriver({ filename: ':memory:' }));
await kernel.use(createServiceAiPlugin({ /* model providers */ }));
await kernel.bootstrap();
```
diff --git a/content/docs/guides/plugin-development.mdx b/content/docs/guides/plugin-development.mdx
index 2423fbecb2..d5e4f52c1e 100644
--- a/content/docs/guides/plugin-development.mdx
+++ b/content/docs/guides/plugin-development.mdx
@@ -8,8 +8,8 @@ description: Step-by-step guide to creating, testing, and publishing ObjectStack
This guide walks you through creating an ObjectStack plugin from scratch — from project setup to testing and registration.
-**Source:** `packages/spec/src/kernel/plugin.zod.ts`
-**Import:** `import { PluginManifestSchema, PluginHookSchema } from '@objectstack/spec/kernel'`
+**Source:** `packages/core/src/types.ts` (the runtime `Plugin` interface)
+**Import:** `import type { Plugin, PluginContext } from '@objectstack/core'`
---
@@ -54,48 +54,39 @@ Create `tsconfig.json`:
---
-## Step 2: Define the Plugin Manifest
+## Step 2: Define the Package Manifest
-Create `src/manifest.ts`:
+A plugin package describes itself with a manifest validated by `ManifestSchema`. Create `src/manifest.ts`:
```typescript
-import { defineStudioPlugin } from '@objectstack/spec';
+import { ManifestSchema } from '@objectstack/spec/kernel';
-export const manifest = defineStudioPlugin({
- name: 'hello_world',
- label: 'Hello World Plugin',
+export const manifest = ManifestSchema.parse({
+ name: 'objectstack-plugin-hello',
version: '1.0.0',
description: 'A simple example plugin that adds a greeting service.',
- author: {
- name: 'Your Name',
- email: 'you@example.com'
- },
- compatibility: {
- minVersion: '3.0.0'
- },
- permissions: [
- 'object:read',
- 'object:write'
- ],
- services: ['greeting'],
- hooks: ['before_record_create']
+ type: 'plugin',
});
```
+
+A runtime plugin's behaviour (services, hooks, objects) is declared in code via the `Plugin` interface — see Step 3. The manifest only describes package metadata.
+
+
---
## Step 3: Implement the Plugin
-Create `src/index.ts`:
+Create `src/index.ts`. A runtime plugin implements the `Plugin` interface: services are registered inside `init(ctx)` via `ctx.registerService`, and lifecycle hooks are wired with `ctx.hook(...)`.
```typescript
-import type { PluginManifest } from '@objectstack/spec/kernel';
+import type { Plugin, PluginContext } from '@objectstack/core';
export interface GreetingService {
greet(name: string): string;
}
-export function createHelloPlugin() {
+export function createHelloPlugin(): Plugin {
const greetingService: GreetingService = {
greet(name: string): string {
return `Hello, ${name}! Welcome to ObjectStack.`;
@@ -104,23 +95,21 @@ export function createHelloPlugin() {
return {
name: 'hello_world',
+ version: '1.0.0',
- // Register services with the kernel
- onRegister(kernel: { registerService: (name: string, service: unknown) => void }) {
- kernel.registerService('greeting', greetingService);
- },
+ init(ctx: PluginContext) {
+ // Register services so other plugins can consume them
+ ctx.registerService('greeting', greetingService);
- // Hook into record lifecycle
- hooks: {
- before_record_create(context: { object: string; data: Record }) {
+ // Hook into record lifecycle
+ ctx.hook('data:beforeInsert', (context: { object: string; data: Record }) => {
if (context.object === 'contact' && context.data.first_name) {
// Auto-generate a greeting field
context.data.welcome_message = greetingService.greet(
context.data.first_name as string
);
}
- return context;
- }
+ });
}
};
}
@@ -138,16 +127,16 @@ import { ObjectSchema } from '@objectstack/spec/data';
export const greetingLogObject = ObjectSchema.create({
name: 'greeting_log',
label: 'Greeting Log',
- fields: [
- { name: 'recipient', label: 'Recipient', type: 'text', required: true },
- { name: 'message', label: 'Message', type: 'text', required: true },
- { name: 'sent_at', label: 'Sent At', type: 'datetime' },
- { name: 'channel', label: 'Channel', type: 'select', options: [
+ fields: {
+ recipient: { label: 'Recipient', type: 'text', required: true },
+ message: { label: 'Message', type: 'text', required: true },
+ sent_at: { label: 'Sent At', type: 'datetime' },
+ channel: { label: 'Channel', type: 'select', options: [
{ label: 'Email', value: 'email' },
{ label: 'SMS', value: 'sms' },
{ label: 'In-App', value: 'in_app', default: true }
]}
- ]
+ }
});
```
@@ -159,48 +148,52 @@ Create `src/index.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
+import type { PluginContext } from '@objectstack/core';
import { createHelloPlugin } from './index';
-describe('HelloPlugin', () => {
- const plugin = createHelloPlugin();
+// Minimal fake PluginContext that captures registered services and hooks.
+function createFakeContext() {
+ const services: Record = {};
+ const hooks: Record void> = {};
+ const ctx = {
+ registerService: (name: string, service: unknown) => { services[name] = service; },
+ hook: (name: string, handler: (...args: any[]) => void) => { hooks[name] = handler; },
+ } as unknown as PluginContext;
+ return { ctx, services, hooks };
+}
+describe('HelloPlugin', () => {
it('should have the correct name', () => {
- expect(plugin.name).toBe('hello_world');
+ expect(createHelloPlugin().name).toBe('hello_world');
});
it('should register the greeting service', () => {
- const services: Record = {};
- plugin.onRegister({
- registerService: (name, service) => { services[name] = service; }
- });
+ const { ctx, services } = createFakeContext();
+ createHelloPlugin().init(ctx);
expect(services).toHaveProperty('greeting');
});
it('should greet by name', () => {
- const services: Record = {};
- plugin.onRegister({
- registerService: (name, service) => { services[name] = service; }
- });
+ const { ctx, services } = createFakeContext();
+ createHelloPlugin().init(ctx);
const greeting = services.greeting as { greet: (name: string) => string };
expect(greeting.greet('Alice')).toBe('Hello, Alice! Welcome to ObjectStack.');
});
it('should add welcome message on contact create', () => {
- const context = {
- object: 'contact',
- data: { first_name: 'Bob' } as Record
- };
- const result = plugin.hooks.before_record_create(context);
- expect(result.data.welcome_message).toBe('Hello, Bob! Welcome to ObjectStack.');
+ const { ctx, hooks } = createFakeContext();
+ createHelloPlugin().init(ctx);
+ const context = { object: 'contact', data: { first_name: 'Bob' } as Record };
+ hooks['data:beforeInsert'](context);
+ expect(context.data.welcome_message).toBe('Hello, Bob! Welcome to ObjectStack.');
});
it('should not modify non-contact objects', () => {
- const context = {
- object: 'task',
- data: { title: 'Test' } as Record
- };
- const result = plugin.hooks.before_record_create(context);
- expect(result.data).not.toHaveProperty('welcome_message');
+ const { ctx, hooks } = createFakeContext();
+ createHelloPlugin().init(ctx);
+ const context = { object: 'task', data: { title: 'Test' } as Record };
+ hooks['data:beforeInsert'](context);
+ expect(context.data).not.toHaveProperty('welcome_message');
});
});
```
@@ -229,6 +222,8 @@ export default defineStack({
});
```
+The kernel loads plugins in dependency order, calling each plugin's `init(ctx)` first (where services and hooks are registered) and then `start(ctx)` once all plugins have initialized.
+
---
## Plugin Best Practices
@@ -274,7 +269,7 @@ function handlePluginError(error: unknown): EnhancedApiError {
### Testing Checklist
-- [ ] Manifest validates against `PluginManifestSchema`
+- [ ] Manifest validates against `ManifestSchema`
- [ ] Services register correctly
- [ ] Hooks fire on expected events
- [ ] Custom objects validate against `ObjectSchema`
@@ -306,8 +301,7 @@ For plugins that extend the **ObjectStack Studio IDE**, use `defineStudioPlugin`
**Source:** `packages/spec/src/studio/plugin.zod.ts`
-**Import:** `import { defineStudioPlugin } from '@objectstack/spec/studio'`
-**Alt Import:** `import { Studio } from '@objectstack/spec'`
+**Import:** `import { defineStudioPlugin } from '@objectstack/spec/studio'`
### Basic Studio Plugin
@@ -448,6 +442,6 @@ Metadata viewers declare which modes they support:
## Next Steps
-- [Common Patterns](/guides/common-patterns) — See how plugins fit into the application architecture
+- [Common Patterns](/docs/guides/common-patterns) — See how plugins fit into the application architecture
- [Error Catalog](/docs/guides/cheatsheets/error-catalog) — Return proper error codes from your plugin
- [Field Type Gallery](/docs/guides/cheatsheets/field-type-gallery) — Define custom objects with the right field types
diff --git a/content/docs/guides/plugins.mdx b/content/docs/guides/plugins.mdx
index 7820987981..8ad393060d 100644
--- a/content/docs/guides/plugins.mdx
+++ b/content/docs/guides/plugins.mdx
@@ -92,9 +92,20 @@ interface PluginContext {
/** Register a service for other plugins to consume */
registerService(name: string, service: any): void;
+ /** Register a lifecycle-managed service factory (e.g. SCOPED per-project services) */
+ registerServiceFactory(
+ name: string,
+ factory: (ctx: PluginContext, scopeId?: string) => any,
+ lifecycle?: ServiceLifecycle,
+ dependencies?: string[],
+ ): void;
+
/** Get a service registered by another plugin */
getService(name: string): T;
+ /** Get a per-scope service instance (e.g. per-project / per-environment) */
+ getServiceScoped(name: string, scopeId: string): Promise;
+
/** Replace an existing service implementation */
replaceService(name: string, implementation: T): void;
@@ -192,9 +203,11 @@ export const myFeaturePlugin: Plugin = {
const myService = { greet: (name: string) => `Hello, ${name}!` };
ctx.registerService('my-feature', myService);
- // Listen for hooks from other plugins
- ctx.hook('data:afterInsert', async (object, record) => {
- ctx.logger.info(`Record inserted into ${object}`, { record });
+ // Listen for kernel hooks (e.g. run logic once the system is operational).
+ // Note: object lifecycle hooks like beforeInsert/afterInsert are defined on
+ // the object itself (L2 hooks), not subscribed via ctx.hook.
+ ctx.hook('kernel:ready', async () => {
+ ctx.logger.info('Kernel is ready');
});
},
@@ -236,7 +249,7 @@ Plugins are configured in `objectstack.config.ts`:
```typescript
import { defineStack } from '@objectstack/spec';
-import authPlugin from '@objectstack/plugin-auth';
+import { AuthPlugin } from '@objectstack/plugin-auth';
import myPlugin from './src/plugins/my-plugin';
export default defineStack({
@@ -252,7 +265,7 @@ export default defineStack({
// Production plugins
plugins: [
- authPlugin,
+ new AuthPlugin(),
myPlugin,
],
@@ -284,12 +297,16 @@ plugins — there is no dedicated CLI subcommand for this in v1.
```typescript
import { defineStack } from '@objectstack/spec';
-import authPlugin from '@objectstack/plugin-auth';
+import { AuthPlugin } from '@objectstack/plugin-auth';
export default defineStack({
manifest: { /* ... */ },
plugins: [
- authPlugin({ providers: ['github'] }),
+ new AuthPlugin({
+ socialProviders: {
+ github: { clientId: '...', clientSecret: '...' },
+ },
+ }),
// add more plugins here
],
devPlugins: [
@@ -317,7 +334,7 @@ ObjectStack ships with several official plugins:
Authentication and identity management powered by better-auth.
- OAuth, 2FA, passkeys, magic links
- Session management
-- Depends on: `com.objectstack.server.hono`
+- Depends on: `com.objectstack.engine.objectql`
### `@objectstack/plugin-hono-server`
HTTP server integration using [Hono](https://hono.dev).
@@ -356,7 +373,7 @@ This means a minimal config like this already works:
```typescript
export default defineStack({
- manifest: { name: 'demo', version: '1.0.0' },
+ manifest: { id: 'com.example.demo', type: 'app', name: 'demo', version: '1.0.0' },
objects: [myObject],
});
```
@@ -422,7 +439,7 @@ const securePlugin: PluginMetadata = {
apiKey: z.string().min(1),
region: z.enum(['us', 'eu', 'ap']),
}),
- signature: 'sha256:abc123...',
+ signature: 'ed25519:key-1:',
async init(ctx) { /* ... */ },
};
@@ -448,143 +465,60 @@ const slowPlugin: PluginMetadata = {
Plugins can extend the ObjectStack CLI (`os`) with custom commands. This enables third-party packages — such as marketplace tools, deployment utilities, or domain-specific workflows — to register new top-level subcommands.
-#### How It Works
+The CLI is built on [oclif](https://oclif.io). Command extension is handled entirely by oclif's plugin system: a plugin ships oclif `Command` classes, declares oclif config in its `package.json`, and is installed into the CLI with `os plugins install `. The host project's `objectstack.config.ts` does **not** determine CLI command availability.
-```
-┌──────────────────────────────────────────────────────────┐
-│ os (CLI) │
-│ ┌────────────┐ ┌────────────┐ ┌─────────────────────┐ │
-│ │ Built-in │ │ Built-in │ │ Plugin Commands │ │
-│ │ init, dev │ │ plugin, │ │ marketplace, │ │
-│ │ compile .. │ │ generate │ │ deploy, ... │ │
-│ └────────────┘ └────────────┘ └─────────────────────┘ │
-│ ▲ │
-│ │ │
-│ loadPluginCommands() │
-│ reads objectstack.config.ts │
-│ discovers contributes.commands │
-│ dynamically imports modules │
-└──────────────────────────────────────────────────────────┘
-```
+#### Step 1: Add oclif Config to `package.json`
-1. The plugin declares `contributes.commands` in its manifest.
-2. The CLI scans installed plugins at startup via `loadPluginCommands()`.
-3. Each plugin module is dynamically imported and its exported Commander.js `Command` instances are registered.
+The plugin package declares its CLI commands location and oclif metadata:
-#### Step 1: Declare Commands in the Manifest
-
-Add a `contributes.commands` section to the plugin's manifest:
-
-```typescript
-// objectstack.config.ts (plugin package)
-import { defineStack } from '@objectstack/spec';
-
-export default defineStack({
- manifest: {
- id: 'com.acme.marketplace',
- namespace: 'marketplace',
- version: '1.0.0',
- type: 'plugin',
- name: 'Marketplace Plugin',
- contributes: {
- commands: [
- {
- name: 'marketplace',
- description: 'Manage marketplace applications',
- module: './dist/cli.js', // optional, defaults to package main
- },
- ],
- },
- },
-});
+```json
+{
+ "name": "@acme/plugin-marketplace",
+ "version": "1.0.0",
+ "type": "module",
+ "files": ["dist"],
+ "oclif": {
+ "commands": {
+ "strategy": "pattern",
+ "target": "./dist/commands"
+ }
+ }
+}
```
-Each command entry has:
-
-| Property | Type | Required | Description |
-|:---------|:-----|:---------|:------------|
-| `name` | `string` | ✅ | CLI command name (lowercase, hyphens allowed) |
-| `description` | `string` | optional | Help text shown in `os --help` |
-| `module` | `string` | optional | Module path exporting Commander.js commands |
-
-#### Step 2: Implement the CLI Module
+#### Step 2: Implement oclif Command Classes
-Create a module that exports Commander.js `Command` instances. The CLI supports three export forms:
+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/cli.ts
-import { Command } from 'commander';
-
-// ── Subcommands ──────────────────────────────────────────
-
-const publishCommand = new Command('publish')
- .description('Publish an app to the marketplace')
- .argument('', 'Package to publish')
- .option('--public', 'Make publicly visible')
- .action(async (pkg: string, options: { public?: boolean }) => {
- console.log(`Publishing ${pkg}...`);
- // ... publish logic
- });
-
-const searchCommand = new Command('search')
- .description('Search marketplace apps')
- .argument('', 'Search query')
- .option('-l, --limit ', 'Max results', '10')
- .action(async (query: string, options: { limit: string }) => {
- console.log(`Searching for "${query}"...`);
- // ... search logic
- });
-
-const installCommand = new Command('install')
- .description('Install an app from the marketplace')
- .argument('', 'App identifier')
- .action(async (app: string) => {
- console.log(`Installing ${app}...`);
- // ... install logic
- });
-
-// ── Main Command ─────────────────────────────────────────
-
-const marketplaceCommand = new Command('marketplace')
- .description('Manage marketplace applications')
- .addCommand(publishCommand)
- .addCommand(searchCommand)
- .addCommand(installCommand);
-
-// Export as named array (recommended)
-export const commands = [marketplaceCommand];
-
-// Alternative: export default (single command or array)
-// export default marketplaceCommand;
-// export default [marketplaceCommand, anotherCommand];
-```
+// src/commands/marketplace/search.ts
+import { Args, Command, Flags } from '@oclif/core';
-#### Step 3: Register the Plugin
+export default class MarketplaceSearch extends Command {
+ static description = 'Search marketplace apps';
-Add the plugin to the host project's `objectstack.config.ts`:
+ static args = {
+ query: Args.string({ description: 'Search query', required: true }),
+ };
-```typescript
-// Host project objectstack.config.ts
-import { defineStack } from '@objectstack/spec';
-import marketplacePlugin from '@acme/plugin-marketplace';
+ static flags = {
+ limit: Flags.integer({ char: 'l', description: 'Max results', default: 10 }),
+ };
-export default defineStack({
- manifest: {
- id: 'com.example.my-app',
- version: '1.0.0',
- type: 'app',
- name: 'My App',
- },
- plugins: [
- marketplacePlugin,
- ],
-});
+ async run(): Promise {
+ const { args, flags } = await this.parse(MarketplaceSearch);
+ this.log(`Searching for "${args.query}" (limit ${flags.limit})...`);
+ // ... search logic
+ }
+}
```
-Or install the package and add it to the `plugins` array yourself:
+#### Step 3: Install the Plugin into the CLI
+
+Users add the plugin to their `os` installation with oclif's plugin manager:
```bash
-pnpm add @acme/plugin-marketplace
+os plugins install @acme/plugin-marketplace
```
#### Using the Extended CLI
@@ -596,90 +530,15 @@ 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 install com.acme.crm
-os marketplace publish ./dist --public
-```
-
-#### Complete Example: Deployment Plugin
-
-Here's a full example of a deployment CLI plugin:
-
-```typescript
-// @acme/plugin-deploy/src/cli.ts
-import { Command } from 'commander';
-
-const deployCommand = new Command('deploy')
- .description('Deploy ObjectStack app to cloud')
- .addCommand(
- new Command('staging')
- .description('Deploy to staging environment')
- .option('--skip-tests', 'Skip pre-deploy tests')
- .action(async (options) => {
- console.log('Deploying to staging...');
- if (!options.skipTests) {
- console.log('Running tests first...');
- }
- // deploy logic
- })
- )
- .addCommand(
- new Command('production')
- .description('Deploy to production')
- .option('--confirm', 'Skip confirmation prompt')
- .action(async (options) => {
- if (!options.confirm) {
- // prompt for confirmation
- }
- console.log('Deploying to production...');
- })
- )
- .addCommand(
- new Command('status')
- .description('Check deployment status')
- .action(async () => {
- console.log('Checking deployment status...');
- })
- );
-
-export const commands = [deployCommand];
+os marketplace:search "crm"
```
-```typescript
-// @acme/plugin-deploy/objectstack.config.ts
-import { defineStack } from '@objectstack/spec';
-
-export default defineStack({
- manifest: {
- id: 'com.acme.deploy',
- version: '1.0.0',
- type: 'plugin',
- name: 'Deploy Plugin',
- contributes: {
- commands: [
- {
- name: 'deploy',
- description: 'Deploy ObjectStack app to cloud',
- module: './dist/cli.js',
- },
- ],
- },
- },
-});
-```
-
-Usage:
+To remove a plugin command, uninstall it:
```bash
-os deploy staging --skip-tests
-os deploy production --confirm
-os deploy status
+os plugins uninstall @acme/plugin-marketplace
```
-
-**Graceful Degradation**: If a plugin command fails to load (e.g., missing dependency), the CLI logs a warning in `DEBUG` mode and continues with built-in commands. Plugin commands never block the CLI.
-
-
---
## Directory Structure Convention
@@ -695,7 +554,7 @@ plugin-my-feature/
├── CHANGELOG.md
└── src/
├── index.ts # Entry point (required)
- ├── cli.ts # CLI commands (optional, for contributes.commands)
+ ├── commands/ # oclif CLI commands (optional, for CLI extension)
├── my_feature/ # Domain folder (snake_case)
│ ├── feature.object.ts # Business object
│ ├── feature.view.ts # View definition
diff --git a/content/docs/guides/project-scoping.mdx b/content/docs/guides/project-scoping.mdx
index 02c10f520b..b0895b72f5 100644
--- a/content/docs/guides/project-scoping.mdx
+++ b/content/docs/guides/project-scoping.mdx
@@ -27,14 +27,27 @@ Enable scoped route registration in `objectstack.config.ts`:
```typescript
import { defineStack } from '@objectstack/spec';
-export default defineStack({
+const stack = defineStack({
+ // ...your manifest, objects, apis, etc.
+});
+
+export default {
+ ...stack,
api: {
enableProjectScoping: true,
projectResolution: 'auto',
},
-});
+};
```
+
+`defineStack` validates against a schema that has no top-level `api` field, so
+unknown keys are stripped — passing `api` *inside* `defineStack({ ... })` is
+silently dropped and scoping stays disabled. Attach the `api` block to the
+exported config object instead, as shown above. The CLI reads it from the
+exported config (`config.api`) when registering the REST and dispatcher plugins.
+
+
The option names are historical for compatibility with existing config files;
the route, header, env var, and request context all use `environment`.
@@ -80,8 +93,11 @@ That sends `X-Environment-Id: env_prod` on unscoped requests.
## Resolution order
-`HttpDispatcher.resolveEnvironmentContext` resolves the environment in this
-order:
+The open-source `HttpDispatcher` only parses the scoped URL for an
+environment-id hint (`extractEnvironmentIdFromPath`) and forwards it via
+`prepareResolverHints`. Actual resolution is owned by the host's
+`KernelResolver` (`resolveKernel`, per ADR-0006 Phase 5), which resolves the
+environment in this order:
1. `:environmentId` from `/api/v1/environments/:environmentId/...`
2. Hostname through the configured environment registry
diff --git a/content/docs/guides/public-forms.mdx b/content/docs/guides/public-forms.mdx
index 045c06cb91..ac39212897 100644
--- a/content/docs/guides/public-forms.mdx
+++ b/content/docs/guides/public-forms.mdx
@@ -15,7 +15,7 @@ ObjectStack Forms are Airtable-style **metadata-driven** forms with two render m
Both modes:
- Use the same `FormView` Zod schema (`@objectstack/spec/ui`)
-- Render through the same `` React component in `apps/console`
+- Render through the same `FormPage` renderer shipped by the ObjectUI console (a separate package/repo)
- Honor `?prefill_=` URL params
- Honor `submitBehavior` (thank-you / redirect / continue / next-record)
@@ -93,13 +93,13 @@ The submit handler attaches `permissions: ['guest_portal']` to the anonymous exe
```ts
// hotcrm/src/profiles/guest-portal.profile.ts
-import { defineProfile } from '@objectstack/spec/security';
+import { PermissionSetSchema } from '@objectstack/spec/security';
-export default defineProfile({
+export default PermissionSetSchema.parse({
name: 'guest_portal',
label: 'Public Form Submitters',
- description: 'INSERT-only on Web-to-Lead / Web-to-Case targets.',
- objectPermissions: {
+ isProfile: true,
+ objects: {
lead: { allowCreate: true }, // no read/edit/delete
case: { allowCreate: true },
},
@@ -125,15 +125,16 @@ const leadGuestDefaults: Hook = {
handler: async (ctx: HookContext) => {
const isGuest = !ctx.previous && !ctx.user?.id;
if (!isGuest) return;
- const { input } = ctx;
- if (!input.lead_source) input.lead_source = 'web';
- if (!input.status) input.status = 'new';
- delete (input as Record).owner;
- delete (input as Record).is_converted;
- delete (input as Record).converted_account;
- delete (input as Record).converted_contact;
- delete (input as Record).converted_opportunity;
- delete (input as Record).converted_date;
+ // For inserts the incoming record lives at ctx.input.data, not on ctx.input itself.
+ const rec = ctx.input.data as Record;
+ if (!rec.lead_source) rec.lead_source = 'web';
+ if (!rec.status) rec.status = 'new';
+ delete rec.owner;
+ delete rec.is_converted;
+ delete rec.converted_account;
+ delete rec.converted_contact;
+ delete rec.converted_opportunity;
+ delete rec.converted_date;
},
};
@@ -157,7 +158,7 @@ Returns the form spec + a restricted object schema for the whitelisted fields:
{
"slug": "contact-us",
"object": "lead",
- "label": "Tell us about yourself",
+ "label": null,
"form": { "type": "simple", "sections": [/* … */], "sharing": {/* … */} },
"objectSchema": {
"name": "lead",
@@ -167,6 +168,8 @@ Returns the form spec + a restricted object schema for the whitelisted fields:
}
```
+The top-level `label` is `view.label ?? form.label` — it is **not** taken from a section label. The Section-1 example sets neither, so it comes back `null`; add a `label` to the form view if you want a value here.
+
`objectSchema.fields` contains **only** the fields referenced by the form, so a public client can render labels, types, and select options without an auth-protected meta lookup.
### `POST /api/v1/forms/:slug/submit`
@@ -184,7 +187,7 @@ curl -X POST http://localhost:3000/api/v1/forms/contact-us/submit \
}'
```
-Response on success:
+Response on success (HTTP `201 Created`):
```json
{ "object": "lead", "id": "r7p8cUZoBJbFWudt", "record": {
@@ -200,10 +203,13 @@ Errors:
| Status | Code | When |
|---|---|---|
-| `400 INVALID_REQUEST` | empty body / missing slug |
+| `400 INVALID_REQUEST` | missing / blank slug (an empty body is coerced to `{}` and surfaces as `VALIDATION_FAILED` below, not here) |
| `400 VALIDATION_FAILED` | object schema validators fail (`required`, `format`, `length`, …) |
+| `403 PERMISSION_DENIED` | the resolved profile does not allow create on the target object |
| `404 FORM_NOT_FOUND` | slug not registered on any `sharing.allowAnonymous: true` view |
-| `5xx FORM_SUBMIT_FAILED` | driver / hook threw |
+| `5xx` (generic) | driver / hook threw — submit errors are mapped by `mapDataError`; there is no dedicated `FORM_SUBMIT_FAILED` code |
+
+The companion `GET /api/v1/forms/:slug` route returns `500 FORM_RESOLVE_FAILED` if form resolution itself throws.
### Auth model
@@ -232,9 +238,9 @@ async function submit(slug: string, payload: Record) {
}
```
-The ObjectUI console ships a unified `FormPage` renderer at `/console/f/:slug` (public) and `/console/forms/:name` (internal) that does exactly this.
+The ObjectUI console (a separate package/repo) ships a unified `FormPage` renderer at `/console/f/:slug` (public) and `/console/forms/:name` (internal) that does exactly this.
-## 7. Internal forms (`/console/forms/:name`)
+## 6. Internal forms (`/console/forms/:name`)
Sometimes you want the same FormView metadata to power an authed operator flow — "new lead", "new ticket", a queue triage screen. Set the FormView up normally (no `sharing.allowAnonymous`) and navigate to `/console/forms/`:
@@ -272,7 +278,7 @@ Operators hit `/console/forms/quick_create`. The renderer:
Permissions are enforced server-side just like every other authed write — no `guest_portal` profile required.
-## 8. URL prefill
+## 7. URL prefill
Both modes accept `?prefill_=` query params. Use this to seed forms from email links, CRM segments, or campaign pages:
@@ -283,7 +289,7 @@ Both modes accept `?prefill_=` query params. Use this to seed
The renderer maps each `prefill_` param to the corresponding form field. Values are still subject to validation and (for public mode) the server-side whitelist — prefill is a UX shortcut, not a permissions bypass.
-## 9. `submitBehavior` — what happens after submit
+## 8. `submitBehavior` — what happens after submit
Add a `submitBehavior` discriminated union to a FormView to control the post-submit experience:
@@ -320,15 +326,15 @@ formViews: {
| `continue` | Re-read prefill values and reset state — user can submit another response without reload. |
| `next-record` | Stub for queue contexts; falls back to thank-you when no queue is wired. |
-## 10. `type: 'form'` action — declarative form launchers
+## 9. `type: 'form'` action — declarative form launchers
App actions can declare `type: 'form'` to open a FormView without resorting to free-form URLs. The `target` is the FormView name; the runtime navigates to `/console/forms/:name`.
```ts
// hotcrm/src/actions/new-lead.action.ts
-import { defineAction } from '@objectstack/spec';
+import { Action } from '@objectstack/spec/ui';
-export default defineAction({
+export default Action.create({
name: 'new_lead',
label: 'New Lead',
type: 'form',
@@ -338,13 +344,15 @@ export default defineAction({
Compared to `{ type: 'url', target: '/console/forms/quick_create' }`, the `'form'` variant is portable across runtimes — Studio/console/native shells can route it through their own form renderer instead of a raw navigation.
-## 11. `defaultDetailForm` — bind a FormView to record detail
+## 10. `defaultDetailForm` — bind a FormView to record detail
Set `defaultDetailForm` on an object schema to pin the FormView used by the record-detail / edit screen. This is the Airtable Interface-form binding pattern.
```ts
// hotcrm/src/objects/lead.object.ts
-export default defineObject({
+import { ObjectSchema } from '@objectstack/spec/data';
+
+export default ObjectSchema.create({
name: 'lead',
label: 'Lead',
defaultDetailForm: 'quick_create', // → views.formViews.quick_create
@@ -360,7 +368,7 @@ Resolution order at runtime:
The same FormView can therefore drive **three** experiences — public collection, internal quick-create, and record-detail editing — without duplicating layout metadata.
-## 12. Security checklist (public mode)
+## 11. Security checklist (public mode)
- [x] **Whitelist enforced server-side** — clients cannot widen the field set by hand-crafting JSON.
- [x] **Hook strips server-controlled fields** — `owner`, `status`, `internal_notes`, `is_*` flags, conversion fields are removed even if they survive the whitelist.
@@ -372,6 +380,6 @@ The same FormView can therefore drive **three** experiences — public collectio
## See also
- [`skills/objectstack-ui/SKILL.md`](https://github.com/objectstack-ai/framework/tree/main/skills/objectstack-ui) — full FormView schema reference.
-- [`skills/objectstack-schema/SKILL.md`](https://github.com/objectstack-ai/framework/tree/main/skills/objectstack-schema) — hook + profile authoring patterns.
+- [`skills/objectstack-data/SKILL.md`](https://github.com/objectstack-ai/framework/tree/main/skills/objectstack-data) — object, hook + permission-set authoring patterns.
- [Hook & Action Bodies](./hook-bodies) — what runs in the sandbox vs. the host.
- [Security](./security) — RBAC, profiles, and the `anonymous` execution context.
diff --git a/content/docs/guides/security.mdx b/content/docs/guides/security.mdx
index 569e061cf1..a3f58d98b9 100644
--- a/content/docs/guides/security.mdx
+++ b/content/docs/guides/security.mdx
@@ -56,54 +56,50 @@ Profiles define a user's baseline permissions.
### Profile Structure
+A profile is modelled as a `PermissionSet` with `isProfile: true` (see
+`packages/spec/src/security/permission.zod.ts`). There is no distinct
+`Profile` type — author it as a `*.profile.ts` metadata entry validated by
+`PermissionSetSchema`.
+
```typescript
-import type { Profile } from '@objectstack/spec/security';
+import type { PermissionSet } from '@objectstack/spec/security';
-export const SalesRepProfile: Profile = {
+export const SalesRepProfile: PermissionSet = {
name: 'sales_rep',
label: 'Sales Representative',
- description: 'Standard sales rep permissions',
-
- // Object-level permissions
- objectPermissions: {
+ isProfile: true,
+
+ // Object-level permissions: -> permissions
+ objects: {
account: {
- create: true,
- read: true,
- update: true,
- delete: false,
- viewAll: false, // See all records regardless of owner
- modifyAll: false, // Edit all records regardless of owner
+ allowCreate: true,
+ allowRead: true,
+ allowEdit: true,
+ allowDelete: false,
+ viewAllRecords: false, // See all records regardless of owner
+ modifyAllRecords: false, // Edit all records regardless of owner
},
opportunity: {
- create: true,
- read: true,
- update: true,
- delete: false,
- viewAll: false,
- modifyAll: false,
+ allowCreate: true,
+ allowRead: true,
+ allowEdit: true,
+ allowDelete: false,
+ viewAllRecords: false,
+ modifyAllRecords: false,
},
},
-
- // Field-level permissions
- fieldPermissions: {
- account: {
- annual_revenue: { read: true, update: false },
- description: { read: true, update: true },
- },
- },
-
- // Tab visibility
- tabVisibility: {
- account: 'default', // Default tab
- lead: 'default',
- opportunity: 'default',
- case: 'hidden', // Not visible
- product: 'available', // Available but not default
+
+ // Field-level security: . -> permissions
+ fields: {
+ 'account.annual_revenue': { readable: true, editable: false },
+ 'account.description': { readable: true, editable: true },
},
-
- // Application access
- applicationVisibility: {
- crm_example: true,
+
+ // Tab/app visibility: -> visibility
+ tabPermissions: {
+ app_crm: 'default_on', // Shown by default
+ app_admin: 'hidden', // Not visible
+ app_sales: 'visible', // Available
},
};
```
@@ -112,32 +108,34 @@ export const SalesRepProfile: Profile = {
| Permission | Description |
|------------|-------------|
-| `create` | Create new records |
-| `read` | View records they own or have access to |
-| `update` | Edit records they own or have access to |
-| `delete` | Delete records they own or have access to |
-| `viewAll` | View ALL records regardless of ownership |
-| `modifyAll` | Edit ALL records regardless of ownership |
+| `allowCreate` | Create new records |
+| `allowRead` | View records they own or have access to |
+| `allowEdit` | Edit records they own or have access to |
+| `allowDelete` | Delete records they own or have access to |
+| `allowTransfer` | Change record ownership |
+| `allowRestore` | Restore soft-deleted records from trash (undelete) |
+| `allowPurge` | Permanently delete records (hard delete / GDPR) |
+| `viewAllRecords` | View ALL records regardless of ownership |
+| `modifyAllRecords` | Edit ALL records regardless of ownership |
### Standard Profiles
#### Sales Representative
```typescript
-export const SalesRepProfile: Profile = {
+export const SalesRepProfile: PermissionSet = {
name: 'sales_rep',
- objectPermissions: {
- lead: { create: true, read: true, update: true, delete: false },
- account: { create: true, read: true, update: true, delete: false },
- contact: { create: true, read: true, update: true, delete: false },
- opportunity: { create: true, read: true, update: true, delete: false },
- quote: { create: true, read: true, update: true, delete: false },
- product: { create: false, read: true, update: false, delete: false },
+ isProfile: true,
+ objects: {
+ lead: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
+ account: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
+ contact: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
+ opportunity: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
+ quote: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
+ product: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false },
},
- fieldPermissions: {
- account: {
- annual_revenue: { read: true, update: false }, // Read-only
- },
+ fields: {
+ 'account.annual_revenue': { readable: true, editable: false }, // Read-only
},
};
```
@@ -145,16 +143,17 @@ export const SalesRepProfile: Profile = {
#### Sales Manager
```typescript
-export const SalesManagerProfile: Profile = {
+export const SalesManagerProfile: PermissionSet = {
name: 'sales_manager',
- objectPermissions: {
- lead: {
- create: true, read: true, update: true, delete: true,
- viewAll: true, modifyAll: true
+ isProfile: true,
+ objects: {
+ lead: {
+ allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true,
+ viewAllRecords: true, modifyAllRecords: true
},
- account: {
- create: true, read: true, update: true, delete: true,
- viewAll: true, modifyAll: true
+ account: {
+ allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true,
+ viewAllRecords: true, modifyAllRecords: true
},
// ... full access to sales objects
},
@@ -164,19 +163,18 @@ export const SalesManagerProfile: Profile = {
#### Service Agent
```typescript
-export const ServiceAgentProfile: Profile = {
+export const ServiceAgentProfile: PermissionSet = {
name: 'service_agent',
- objectPermissions: {
- case: { create: true, read: true, update: true, delete: false },
- task: { create: true, read: true, update: true, delete: true },
- account: { create: false, read: true, update: false, delete: false },
- contact: { create: false, read: true, update: true, delete: false },
+ isProfile: true,
+ objects: {
+ case: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
+ task: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true },
+ account: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false },
+ contact: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false },
},
- fieldPermissions: {
- case: {
- is_sla_violated: { read: true, update: false },
- resolution_time_hours: { read: true, update: false },
- },
+ fields: {
+ 'case.is_sla_violated': { readable: true, editable: false },
+ 'case.resolution_time_hours': { readable: true, editable: false },
},
};
```
@@ -193,40 +191,31 @@ import type { PermissionSet } from '@objectstack/spec/security';
export const AdvancedReportingPermissionSet: PermissionSet = {
name: 'advanced_reporting',
label: 'Advanced Reporting',
- description: 'Additional permissions for advanced reporting',
-
- objectPermissions: {
+
+ objects: {
opportunity: {
- viewAll: true, // Override profile restriction
+ viewAllRecords: true, // Override profile restriction
},
account: {
- viewAll: true,
+ viewAllRecords: true,
},
},
-
- fieldPermissions: {
- opportunity: {
- amount: { read: true },
- probability: { read: true },
- },
- },
-
- systemPermissions: {
- runReports: true,
- exportReports: true,
- createDashboards: true,
+
+ fields: {
+ 'opportunity.amount': { readable: true },
+ 'opportunity.probability': { readable: true },
},
+
+ // System permissions are a flat array of capability strings
+ systemPermissions: ['run_reports', 'export_reports', 'create_dashboards'],
};
export const BulkDataPermissionSet: PermissionSet = {
name: 'bulk_data_access',
label: 'Bulk Data Access',
- description: 'Permissions for bulk data operations',
-
- systemPermissions: {
- bulkApiEnabled: true,
- viewAllData: true,
- },
+
+ objects: {},
+ systemPermissions: ['bulk_api', 'view_all_data'],
};
```
@@ -234,14 +223,14 @@ export const BulkDataPermissionSet: PermissionSet = {
The framework ships four canonical permission sets that are auto-seeded on
boot by `plugin-security` (see
-`packages/platform-objects/src/security/default-permission-sets.ts`). Use
+`packages/plugins/plugin-security/src/objects/default-permission-sets.ts`). Use
these as the baseline for any new org — tier custom sets on top rather
than redefining the basics.
| Name | Scope | What it unlocks | Notes |
|:--|:--|:--|:--|
| `admin_full_access` | Platform (no RLS) | All objects, all system permissions, Studio + Setup, cross-org reads | Reserved for platform operators. Auto-granted to the bootstrap admin (`bootstrapPlatformAdmin`) — the **first registered human**. The non-loginable seed-data identity `usr_system` (role `system`, provisioned before the first sign-up to own seeded rows) is deliberately skipped, so the real admin wins the promotion. |
-| `organization_admin` | Per-org (`tenant_isolation` RLS, only active when [`plugin-org-scoping`](#objectstackplugin-org-scoping) is loaded) | Wildcard CRUD inside the org, `manage_org_users`, Setup app shell (only org-scoped entries visible), invite members, manage roles assignment | **Read-only** on `sys_role`, `sys_permission_set`, `sys_role_permission_set`, `sys_user_permission_set`, `sys_user_role` to prevent self-elevation. Does **not** see Studio. |
+| `organization_admin` | Per-org (`tenant_isolation` RLS, only active when `@objectstack/plugin-org-scoping` is loaded) | Wildcard CRUD inside the org, `manage_org_users`, Setup app shell (only org-scoped entries visible), invite members, manage roles assignment | **Read-only** on `sys_role`, `sys_permission_set`, `sys_role_permission_set`, `sys_user_permission_set`, `sys_user_role` to prevent self-elevation. Does **not** see Studio. |
| `member_default` | Per-org | Standard end-user CRUD on org records | Default profile for invited members. |
| `viewer_readonly` | Per-org | Read access only | For auditors / read-only stakeholders. |
@@ -251,7 +240,7 @@ than redefining the basics.
> mode `plugin-security` detects the missing `org-scoping` service and
> strips the wildcard policy so reads / writes aren't accidentally
> filtered by an unset `current_user.organization_id`. See the
-> [Org-Scoping Plugin README](/packages/plugins/plugin-org-scoping/README.md)
+> [Org-Scoping Plugin README](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-org-scoping/README.md)
> for boot order and per-org seed-replay details.
#### Auto-grant for organization admins
@@ -277,74 +266,56 @@ remain gated by `setup.access` so org admins can manage them.
### Assigning Permission Sets
+Permission-set grants are plain data rows, not helper-function calls. Assign a
+set to a user by inserting a `sys_user_permission_set` row, or to a role by
+inserting a `sys_role_permission_set` row:
+
```typescript
-// Assign to user
-await assignPermissionSet({
- userId: 'user123',
- permissionSetName: 'advanced_reporting',
+// Grant a permission set to a single user
+await objectql.object('sys_user_permission_set').insert({
+ user_id: 'user123',
+ permission_set: 'advanced_reporting',
});
-// Assign to multiple users
-await assignPermissionSetToGroup({
- permissionSetName: 'bulk_data_access',
- userGroup: 'data_analysts',
+// Grant a permission set to everyone in a role
+await objectql.object('sys_role_permission_set').insert({
+ role: 'data_analysts',
+ permission_set: 'bulk_data_access',
});
```
+Org-admin grants are reconciled automatically by a `plugin-security` lifecycle
+hook (see [Auto-grant for organization admins](#auto-grant-for-organization-admins))
+— you do not insert those rows by hand.
+
---
## Role Hierarchy
Roles control record-level access through a hierarchy.
+The hierarchy is derived from the `parent` link on each role — there is no
+`RoleHierarchy` container schema. Author roles as individual `*.role.ts`
+entries (each matching `RoleSchema`), using `parent` to point at the role they
+report to:
+
```typescript
-export const RoleHierarchy = {
- name: 'crm_role_hierarchy',
- label: 'CRM Role Hierarchy',
-
- roles: [
- // Top level
- {
- name: 'executive',
- label: 'Executive',
- parentRole: null,
- },
-
- // Sales hierarchy
- {
- name: 'sales_director',
- label: 'Sales Director',
- parentRole: 'executive',
- },
- {
- name: 'sales_manager',
- label: 'Sales Manager',
- parentRole: 'sales_director',
- },
- {
- name: 'sales_rep',
- label: 'Sales Representative',
- parentRole: 'sales_manager',
- },
-
- // Service hierarchy
- {
- name: 'service_director',
- label: 'Service Director',
- parentRole: 'executive',
- },
- {
- name: 'service_manager',
- label: 'Service Manager',
- parentRole: 'service_director',
- },
- {
- name: 'service_agent',
- label: 'Service Agent',
- parentRole: 'service_manager',
- },
- ],
-};
+import type { Role } from '@objectstack/spec/identity';
+
+export const roles: Role[] = [
+ // Top level
+ { name: 'executive', label: 'Executive' },
+
+ // Sales hierarchy
+ { name: 'sales_director', label: 'Sales Director', parent: 'executive' },
+ { name: 'sales_manager', label: 'Sales Manager', parent: 'sales_director' },
+ { name: 'sales_rep', label: 'Sales Representative', parent: 'sales_manager' },
+
+ // Service hierarchy
+ { name: 'service_director', label: 'Service Director', parent: 'executive' },
+ { name: 'service_manager', label: 'Service Manager', parent: 'service_director' },
+ { name: 'service_agent', label: 'Service Agent', parent: 'service_manager' },
+];
```
### How Role Hierarchy Works
@@ -376,32 +347,17 @@ Sharing rules extend access beyond the role hierarchy.
Set baseline access for all users:
+Each object's OWD is a single value from the `OWDModel` enum (`private`,
+`public_read`, `public_read_write`, `controlled_by_parent`):
+
```typescript
export const OrganizationDefaults = {
- lead: {
- internalAccess: 'private', // Users see only their own records
- externalAccess: 'private',
- },
- account: {
- internalAccess: 'private',
- externalAccess: 'private',
- },
- contact: {
- internalAccess: 'controlled_by_parent', // Access controlled by Account
- externalAccess: 'private',
- },
- opportunity: {
- internalAccess: 'private',
- externalAccess: 'private',
- },
- campaign: {
- internalAccess: 'public_read_only', // All users can read
- externalAccess: 'private',
- },
- product: {
- internalAccess: 'public_read_only',
- externalAccess: 'private',
- },
+ lead: 'private', // Users see only their own records
+ account: 'private',
+ contact: 'controlled_by_parent', // Access controlled by parent (Account)
+ opportunity: 'private',
+ campaign: 'public_read', // All users can read
+ product: 'public_read',
};
```
@@ -410,7 +366,7 @@ export const OrganizationDefaults = {
| Level | Description |
|-------|-------------|
| `private` | Owner only (+ role hierarchy) |
-| `public_read_only` | All users can read |
+| `public_read` | All users can read |
| `public_read_write` | All users can read and edit |
| `controlled_by_parent` | Controlled by parent object |
@@ -419,32 +375,25 @@ export const OrganizationDefaults = {
Share records based on field criteria:
```typescript
+import type { SharingRule } from '@objectstack/spec/security';
+
export const AccountTeamSharingRule: SharingRule = {
name: 'account_team_sharing',
label: 'Share Active Customers with Sales Team',
- objectName: 'account',
- type: 'criteria_based',
-
- // Criteria: Which records to share
- criteria: {
- type: { $eq: 'customer' },
- is_active: { $eq: true },
- },
-
- // Who to share with
+ type: 'criteria',
+ object: 'account',
+
+ // Predicate (CEL): which records to share
+ condition: P`record.type == "customer" && record.is_active == true`,
+
+ // Who to share with (a single recipient: user, group, role, etc.)
sharedWith: {
type: 'role',
- roles: ['sales_manager', 'sales_director'],
+ value: 'sales_manager',
},
-
- // Access level granted
- accessLevel: 'read_write',
-
- // Also share related records
- includeRelatedObjects: [
- { objectName: 'contact', accessLevel: 'read_only' },
- { objectName: 'opportunity', accessLevel: 'read_only' },
- ],
+
+ // Access level granted: read | edit | full
+ accessLevel: 'edit',
};
```
@@ -455,53 +404,24 @@ Share based on record owner characteristics:
```typescript
export const OpportunityOwnerSharingRule: SharingRule = {
name: 'opportunity_owner_sharing',
- label: 'Share Opportunities within Same Territory',
- objectName: 'opportunity',
- type: 'owner_based',
-
- // Share records owned by users in these roles
+ label: 'Share Sales Rep Opportunities with Managers',
+ type: 'owner',
+ object: 'opportunity',
+
+ // Share records owned by this group/role
ownedBy: {
type: 'role',
- roles: ['sales_rep'],
+ value: 'sales_rep',
},
-
- // Share with users in these roles
+
+ // Share with this recipient
sharedWith: {
type: 'role',
- roles: ['sales_rep'],
- sameTerritory: true, // Only same territory
+ value: 'sales_manager',
},
-
- accessLevel: 'read_only',
-};
-```
-
-### Territory-Based Sharing
-Share based on geographic territories:
-
-```typescript
-export const TerritorySharingRules = [
- {
- name: 'north_america_territory',
- label: 'North America Territory',
- objectName: 'account',
- type: 'territory_based',
-
- criteria: {
- billing_address: {
- country: { $in: ['US', 'CA', 'MX'] },
- },
- },
-
- sharedWith: {
- type: 'territory',
- territory: 'north_america',
- },
-
- accessLevel: 'read_write',
- },
-];
+ accessLevel: 'read',
+};
```
### Analytics and Dataset Read Scope
@@ -527,27 +447,17 @@ Control visibility and editability of specific fields.
### Field Permissions in Profiles
+Field permissions are keyed `.` and use `readable` / `editable`:
+
```typescript
-fieldPermissions: {
- account: {
- // Field-level permissions
- annual_revenue: {
- read: true, // Can view
- update: false // Cannot edit
- },
- description: {
- read: true,
- update: true
- },
- ssn: {
- read: false, // Hidden field
- update: false
- },
- },
- opportunity: {
- amount: { read: true, update: true },
- probability: { read: true, update: false },
- },
+fields: {
+ // Read-only: visible but not editable
+ 'account.annual_revenue': { readable: true, editable: false },
+ 'account.description': { readable: true, editable: true },
+ // Hidden: not visible at all
+ 'account.ssn': { readable: false, editable: false },
+ 'opportunity.amount': { readable: true, editable: true },
+ 'opportunity.probability': { readable: true, editable: false },
}
```
@@ -555,13 +465,13 @@ fieldPermissions: {
```typescript
// Hidden: Field not visible at all
-{ read: false, update: false }
+{ readable: false, editable: false }
// Read-Only: Field visible but not editable
-{ read: true, update: false }
+{ readable: true, editable: false }
// Editable: Field visible and editable
-{ read: true, update: true }
+{ readable: true, editable: true }
```
### Server-side enforcement (fail-closed)
@@ -715,51 +625,50 @@ writes.
Complete security setup for a sales team:
```typescript
-// 1. Organization-Wide Defaults
-OrganizationDefaults = {
- account: { internalAccess: 'private' },
- opportunity: { internalAccess: 'private' },
- contact: { internalAccess: 'controlled_by_parent' },
+// 1. Organization-Wide Defaults (one OWDModel value per object)
+const OrganizationDefaults = {
+ account: 'private',
+ opportunity: 'private',
+ contact: 'controlled_by_parent',
};
-// 2. Role Hierarchy
-RoleHierarchy = {
- roles: [
- { name: 'sales_vp', parentRole: null },
- { name: 'sales_manager', parentRole: 'sales_vp' },
- { name: 'sales_rep', parentRole: 'sales_manager' },
- ],
-};
+// 2. Role Hierarchy (flat roles linked by `parent`)
+const roles = [
+ { name: 'sales_vp', label: 'Sales VP' },
+ { name: 'sales_manager', label: 'Sales Manager', parent: 'sales_vp' },
+ { name: 'sales_rep', label: 'Sales Rep', parent: 'sales_manager' },
+];
-// 3. Profiles
-SalesRepProfile = {
- objectPermissions: {
- account: { create: true, read: true, update: true, delete: false },
- opportunity: { create: true, read: true, update: true, delete: false },
+// 3. Profile (a PermissionSet with isProfile: true)
+const SalesRepProfile = {
+ name: 'sales_rep',
+ isProfile: true,
+ objects: {
+ account: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
+ opportunity: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
},
- fieldPermissions: {
- account: {
- annual_revenue: { read: true, update: false }, // Read-only
- },
+ fields: {
+ 'account.annual_revenue': { readable: true, editable: false }, // Read-only
},
};
-// 4. Sharing Rules
-AccountSharingRule = {
- // Share high-value accounts with all sales reps
- criteria: { annual_revenue: { $gte: 1000000 } },
- sharedWith: { type: 'role', roles: ['sales_rep'] },
- accessLevel: 'read_only',
+// 4. Sharing Rule (criteria type with a CEL condition)
+const AccountSharingRule = {
+ name: 'high_value_accounts',
+ type: 'criteria',
+ object: 'account',
+ // Share high-value accounts with sales reps
+ condition: P`record.annual_revenue >= 1000000`,
+ sharedWith: { type: 'role', value: 'sales_rep' },
+ accessLevel: 'read',
};
-// 5. Permission Set
-AdvancedReportingPermissionSet = {
+// 5. Permission Set (systemPermissions is a string array)
+const AdvancedReportingPermissionSet = {
+ name: 'advanced_reporting',
+ objects: {},
// For sales analysts
- systemPermissions: {
- runReports: true,
- exportReports: true,
- viewAllData: true,
- },
+ systemPermissions: ['run_reports', 'export_reports', 'view_all_data'],
};
```
diff --git a/content/docs/guides/seed-data.mdx b/content/docs/guides/seed-data.mdx
index b390f1d193..e8fd11735d 100644
--- a/content/docs/guides/seed-data.mdx
+++ b/content/docs/guides/seed-data.mdx
@@ -1,11 +1,11 @@
---
title: Seed Data & Fixtures
-description: Populate ObjectStack objects with bootstrap data, reference records, and demo fixtures using defineDataset()
+description: Populate ObjectStack objects with bootstrap data, reference records, and demo fixtures using defineSeed()
---
# Seed Data & Fixtures
-`defineDataset()` is the canonical way to define seed data in ObjectStack. It provides
+`defineSeed()` is the canonical way to define seed data in ObjectStack. It provides
compile-time type safety by inferring valid field keys directly from your object
definition, so typos in record field names are caught before the code runs.
@@ -20,10 +20,10 @@ Use seed data for:
## Quick Start
```typescript
-import { defineDataset } from '@objectstack/spec/data';
+import { defineSeed } from '@objectstack/spec/data';
import { Account } from './objects/account.object';
-export const accountsSeed = defineDataset(Account, {
+export const accountsSeed = defineSeed(Account, {
externalId: 'name', // field used as the upsert / idempotency key
mode: 'upsert', // create if new, update if found
env: ['dev', 'test'], // only load in dev and test environments
@@ -48,6 +48,13 @@ The first argument is the **object definition** (the exported constant from your
object file), not a string. This lets TypeScript validate every field name in
`records` against the object's `fields` map at compile time.
+
+ Import `defineSeed` from `@objectstack/spec/data`. Do not confuse it with
+ `defineDataset` (from `@objectstack/spec/ui`), which is the unrelated ADR-0021
+ analytics-dataset helper — it takes a single config object and is **not** a seed
+ factory.
+
+
---
## Import Modes
@@ -66,7 +73,7 @@ record (matched by `externalId`).
### `upsert` — Recommended Default
```typescript
-defineDataset(Currency, {
+defineSeed(Currency, {
externalId: 'code',
mode: 'upsert',
records: [
@@ -80,7 +87,7 @@ defineDataset(Currency, {
### `ignore` — Bootstrap Without Overwriting
```typescript
-defineDataset(SystemRole, {
+defineSeed(SystemRole, {
externalId: 'code',
mode: 'ignore',
records: [
@@ -95,7 +102,7 @@ defineDataset(SystemRole, {
```typescript
// ⚠️ Deletes ALL records in the object before inserting.
// Only use for cache or lookup tables with no user-generated data.
-defineDataset(ExchangeRateCache, {
+defineSeed(ExchangeRateCache, {
externalId: 'key',
mode: 'replace',
env: ['dev'],
@@ -115,7 +122,7 @@ default is `['prod', 'dev', 'test']` — all environments.
```typescript
// Reference data — safe for all environments (default)
-defineDataset(Country, {
+defineSeed(Country, {
// env omitted → defaults to ['prod', 'dev', 'test']
records: [
{ code: 'US', name: 'United States' },
@@ -124,7 +131,7 @@ defineDataset(Country, {
});
// Demo data — never reaches production
-defineDataset(Account, {
+defineSeed(Account, {
env: ['dev', 'test'],
records: [
{ name: 'Demo Corp', type: 'customer' },
@@ -132,7 +139,7 @@ defineDataset(Account, {
});
// Automated test fixtures — CI/CD only
-defineDataset(TestUser, {
+defineSeed(TestUser, {
env: ['test'],
records: [
{ email: 'ci-admin@example.com', role: 'admin' },
@@ -144,14 +151,14 @@ defineDataset(TestUser, {
## Type Safety
-`defineDataset()` infers valid field keys from the object definition you pass as the
+`defineSeed()` infers valid field keys from the object definition you pass as the
first argument. If you reference a field that does not exist on the object, TypeScript
reports an error immediately.
```typescript
import { Account } from './objects/account.object';
-defineDataset(Account, {
+defineSeed(Account, {
records: [
{
name: 'Test Corp',
@@ -164,8 +171,8 @@ defineDataset(Account, {
});
```
-This is a major advantage over writing plain JSON — always use `defineDataset()`
-over the raw `DatasetSchema.parse()` call.
+This is a major advantage over writing plain JSON — always use `defineSeed()`
+over the raw `SeedSchema.parse()` call.
---
@@ -177,7 +184,7 @@ runner resolves natural keys to database IDs automatically at load time.
```typescript
// Step 1 — seed the parent object first
-const accountsSeed = defineDataset(Account, {
+const accountsSeed = defineSeed(Account, {
externalId: 'name',
records: [
{ name: 'Acme Corporation', type: 'customer' },
@@ -185,7 +192,7 @@ const accountsSeed = defineDataset(Account, {
});
// Step 2 — seed the child object, referencing the parent by natural key
-const contactsSeed = defineDataset(Contact, {
+const contactsSeed = defineSeed(Contact, {
externalId: 'email',
records: [
{
@@ -211,9 +218,10 @@ or identity-derived seed values — a literal `new Date()` would ship the packag
author's clock to every customer and break build determinism.
```typescript
-import { defineDataset, cel } from '@objectstack/spec';
+import { defineSeed } from '@objectstack/spec/data';
+import { cel } from '@objectstack/spec';
-defineDataset(Opportunity, {
+defineSeed(Opportunity, {
records: [{
name: 'Acme Q3 Renewal',
close_date: cel`daysFromNow(45)`,
@@ -238,7 +246,7 @@ This is the single canonical convention; there is no `currentUser()`, `@admin`,
or similar special syntax.
```typescript
-defineDataset(Project, {
+defineSeed(Project, {
externalId: 'code',
records: [{
code: 'bootstrap',
@@ -358,10 +366,10 @@ re-exports and orders them keeps the entry point clean.
---
-## `defineDataset()` API Reference
+## `defineSeed()` API Reference
```typescript
-function defineDataset<
+function defineSeed<
const TObj extends { name: string; fields: Record }
>(
objectDef: TObj,
@@ -371,10 +379,10 @@ function defineDataset<
env?: Array<'prod' | 'dev' | 'test'>; // default: ['prod','dev','test']
records: Array>>;
}
-): Dataset
+): Seed
```
-The returned `Dataset` object is a plain serialisable value — pass it to your
+The returned `Seed` object is a plain serialisable value — pass it to your
stack's seed runner or store it in an export array.
---
diff --git a/content/docs/guides/skills.mdx b/content/docs/guides/skills.mdx
index 3a485fc80a..9fa40bc326 100644
--- a/content/docs/guides/skills.mdx
+++ b/content/docs/guides/skills.mdx
@@ -53,7 +53,7 @@ ObjectStack ships **9 domain-specific skills**. Each is self-contained — an AI
| 6 | [AI](#ai) | `ai` | `skills/objectstack-ai/` | Design ObjectStack AI agents, tools, skills, conversations, model registry entries, and MCP integrations. |
| 7 | [API](#api) | `api` | `skills/objectstack-api/` | Design the server-side API surface that an ObjectStack runtime exposes — REST/GraphQL endpoints, auth providers, realtime channels, error envelopes, batch/versioning contracts. |
| 8 | [i18n](#i18n) | `i18n` | `skills/objectstack-i18n/` | Author ObjectStack translation bundles — object/field labels, view text, app navigation strings, automation messages — and configure locale fallback, coverage reporting, and the per-locale source layout. |
-| 9 | [Formula](#formula) | `expression` | `skills/objectstack-formula/` | Author CEL expressions used across ObjectStack — formula fields, field conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`), validation / sharing / visibility predicates, flow conditions, and dynamic seed values. Use whenever the user is writing an `F`, `P`, or `cel` tagged-template literal, or asks "how do I express X as a formula / predicate". Do not use for SQL fragments (driver-native), cron schedules (cron dialect), or L2 hook bodies (those belong in objectstack-data). |
+| 9 | [Formula](#formula) | `expression` | `skills/objectstack-formula/` | Author CEL expressions used across ObjectStack — formula fields, field conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`), validation / sharing / visibility predicates, flow conditions, and dynamic seed values. |
---
@@ -173,7 +173,9 @@ Do not use for general i18n library questions unrelated to ObjectStack bundles.
**Domain** `expression` · **Path** `skills/objectstack-formula/`
-Author CEL expressions used across ObjectStack — formula fields, field conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`), validation / sharing / visibility predicates, flow conditions, and dynamic seed values. Use whenever the user is writing an `F`, `P`, or `cel` tagged-template literal, or asks "how do I express X as a formula / predicate". Do not use for SQL fragments (driver-native), cron schedules (cron dialect), or L2 hook bodies (those belong in objectstack-data).
+Author CEL expressions used across ObjectStack — formula fields, field conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`), validation / sharing / visibility predicates, flow conditions, and dynamic seed values.
+
+Use when the user is writing an `F`, `P`, or `cel` tagged-template literal, or asks "how do I express X as a formula / predicate".
Do not use for SQL fragments (driver-native), cron schedules (cron dialect), or L2 hook bodies (those belong in objectstack-data).
@@ -190,13 +192,13 @@ Every skill is a directory under `skills/`:
```
skills/objectstack-{domain}/
├── SKILL.md # required — frontmatter (name, description, domain, tags) + prose guide
-├── references/
-│ └── _index.md # generated — pointers into @objectstack/spec Zod sources
+├── references/ # generated & optional — present only when the skill maps to Zod sources
+│ └── _index.md # pointers into @objectstack/spec Zod sources
├── rules/ # optional — detailed implementation rules
└── evals/ # optional — AI comprehension test cases
```
-`SKILL.md` is the only required file. The `references/_index.md` index is generated by `packages/spec/scripts/build-skill-references.ts` and points into the published `@objectstack/spec` schemas in `node_modules` — skills never bundle copies of the schemas, so they stay version-aligned automatically.
+`SKILL.md` is the only required file. The `references/_index.md` index is generated by `packages/spec/scripts/build-skill-references.ts` and points into the published `@objectstack/spec` schemas in `node_modules` — skills never bundle copies of the schemas, so they stay version-aligned automatically. Skills with no mapped Zod sources (such as Formula) ship without a `references/` directory at all.
The catalog above (names, domains, descriptions) is **generated from each `SKILL.md` frontmatter** by `build-skill-docs.ts`. Edit the frontmatter, not this page — then run `pnpm --filter @objectstack/spec gen:skill-docs`.
diff --git a/skills/README.md b/skills/README.md
index 9b8e9874a6..cdf30c7bf5 100644
--- a/skills/README.md
+++ b/skills/README.md
@@ -24,7 +24,7 @@ authoritative Zod sources in `node_modules/@objectstack/spec/src/...`.
| [AI](./objectstack-ai/SKILL.md) | `ai` | Design ObjectStack AI agents, tools, skills, conversations, model registry entries, and MCP integrations. |
| [API](./objectstack-api/SKILL.md) | `api` | Design the server-side API surface that an ObjectStack runtime exposes — REST/GraphQL endpoints, auth providers, realtime channels, error envelopes, batch/versioning contracts. |
| [i18n](./objectstack-i18n/SKILL.md) | `i18n` | Author ObjectStack translation bundles — object/field labels, view text, app navigation strings, automation messages — and configure locale fallback, coverage reporting, and the per-locale source layout. |
-| [Formula](./objectstack-formula/SKILL.md) | `expression` | Author CEL expressions used across ObjectStack — formula fields, field conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`), validation / sharing / visibility predicates, flow conditions, and dynamic seed values. Use whenever the user is writing an `F`, `P`, or `cel` tagged-template literal, or asks "how do I express X as a formula / predicate". Do not use for SQL fragments (driver-native), cron schedules (cron dialect), or L2 hook bodies (those belong in objectstack-data). |
+| [Formula](./objectstack-formula/SKILL.md) | `expression` | Author CEL expressions used across ObjectStack — formula fields, field conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`), validation / sharing / visibility predicates, flow conditions, and dynamic seed values. |
diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md
index 6556ae36b1..41af9ccee6 100644
--- a/skills/objectstack-formula/SKILL.md
+++ b/skills/objectstack-formula/SKILL.md
@@ -4,7 +4,7 @@ description: >
Author CEL expressions used across ObjectStack — formula fields,
field conditional rules (`visibleWhen`, `readonlyWhen`, `requiredWhen`),
validation / sharing / visibility predicates, flow conditions, and dynamic
- seed values. Use whenever the user is writing an `F`, `P`, or `cel`
+ seed values. Use when the user is writing an `F`, `P`, or `cel`
tagged-template literal, or asks "how do I express X as a formula /
predicate". Do not use for SQL fragments (driver-native), cron schedules
(cron dialect), or L2 hook bodies (those belong in objectstack-data).