diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx
index 6866a7dc39..592e26199c 100644
--- a/content/docs/automation/approvals.mdx
+++ b/content/docs/automation/approvals.mdx
@@ -114,6 +114,113 @@ export const opportunityApproval: Flow = {
For multi-step approvals, chain multiple `approval` nodes. For parallel
approvals, see the aggregating-node pattern in [Flow Metadata](/docs/automation/flows).
+## The full lifecycle — submit to field change
+
+What actually happens between "the flow hits the approval node" and "the record
+says approved":
+
+
+
+### The request opens, the run pauses
+
+The node writes a `sys_approval_request` row: `status: 'pending'`,
+`pending_approvers` (the resolved approver list), and — if you declared
+`approvalStatusField` — it mirrors `'pending'` onto your record's field. The
+record is **locked** against edits while pending (`lockRecord`, default `true`),
+and the flow run parks until a decision arrives.
+
+Only `approvers` is required on the node; everything else has a default
+(`behavior: 'first_response'`, `lockRecord: true`, `maxRevisions: 3`).
+
+
+**`type: 'role'` is not a position.** In an approver entry, `role` means the
+better-auth membership tier (`owner` / `admin` / `member`). Naming a business
+role like `sales_manager` there matches nobody and the request silently has no
+approvers — use `{ type: 'position', value: 'sales_manager' }`.
+
+
+### The approver finds it in their queue
+
+Requests land in **Setup → Approvals → Requests**, whose `my_pending` view
+filters to `status = pending` and `pending_approvers` containing the current
+user. Programmatically:
+
+```bash
+curl -b cookies.txt \
+ "https://your-app.example.com/api/v1/approvals/requests?status=pending&approverId=usr_123"
+```
+
+`approverId` accepts a user id, an email, or `role:` — and takes several
+values (comma-separated or repeated) to cover a person's identities in one call.
+Other filters: `status`, `object`, `recordId`, `submitterId`, `q`, `limit`,
+`offset`.
+
+
+**Opening a request notifies nobody.** There is no built-in "you have an
+approval waiting" message today — an approver only discovers it by looking at
+the queue. If people need to be told, do one of: add a `notify` node next to
+the approval node in the flow (the practical answer), or install a messaging
+service and drive `remind()`. Reminder, escalation, return, and reassignment
+*do* publish notification topics — the initial request doesn't.
+
+
+### The decision
+
+```bash
+curl -b cookies.txt -X POST \
+ https://your-app.example.com/api/v1/approvals/requests/req_456/approve \
+ -H "Content-Type: application/json" \
+ -d '{ "comment": "Within budget." }'
+# POST .../reject for the other direction; body: { actorId?, comment? }
+```
+
+`actorId` defaults to the caller. The actor **must** be in `pending_approvers`
+or the call returns 403 (`FORBIDDEN: actor '…' is not a pending approver`); a
+request that isn't pending returns 409 (`INVALID_STATE`). Always go through
+these endpoints — never resume the flow run directly.
+
+
+**With `behavior: 'unanimous'`, an approve is not the end.** Every approver but
+the last only trims `pending_approvers`; the request stays `pending`
+(`finalized: false`) and the flow stays parked. Only the final approval
+finalizes it.
+
+
+### The record changes and the flow resumes
+
+On finalization the request row gets `status: 'approved'` (or `'rejected'`),
+`pending_approvers: null`, and `completed_at`; your `approvalStatusField` is
+mirrored to the same value, and the record unlocks. The parked run resumes down
+the `approve` or `reject` branch.
+
+Statuses in full: `pending`, `approved`, `rejected`, `recalled`, `returned`.
+
+
+
+### Timeouts and escalation
+
+`escalation` is real, not decorative: set `enabled: true` and `timeoutHours`,
+and pick an `action` — `notify` (default), `reassign`, `auto_approve`, or
+`auto_reject`. Auto decisions run through the normal decide path, so the flow
+resumes exactly as if a human had clicked. Every escalation writes an audit row.
+
+
+**Escalation needs the job service.** The plugin sweeps pending requests on an
+internal timer (~5 min). Without a `job` service registered, SLA timers are
+**display-only** and no escalation ever fires.
+
+
+### Error codes
+
+| Code | HTTP | Meaning |
+|:---|:---|:---|
+| `VALIDATION_FAILED` | 400 | Malformed request |
+| `FORBIDDEN` | 403 | Actor isn't a pending approver |
+| `REQUEST_NOT_FOUND` | 404 | No such request |
+| `DUPLICATE_REQUEST` | 409 | A pending request already exists for this record |
+| `INVALID_STATE` | 409 | The request is no longer pending |
+| `THROTTLED` | 429 | Rate limited |
+
### Best practices
✅ **DO:**
diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx
index d8e5d986cf..d8541eac01 100644
--- a/content/docs/automation/flows.mdx
+++ b/content/docs/automation/flows.mdx
@@ -31,9 +31,10 @@ const approvalFlow = {
type: 'decision',
label: 'Check Amount',
config: {
+ // decision expressions use {braces} around variables — see Expressions in flows
conditions: [
- { label: 'High Value', expression: 'order_amount > 10000' },
- { label: 'Standard', expression: 'order_amount <= 10000' },
+ { label: 'High Value', expression: '{order_amount} > 10000' },
+ { label: 'Standard', expression: '{order_amount} <= 10000' },
],
},
},
@@ -41,7 +42,7 @@ const approvalFlow = {
id: 'auto_approve',
type: 'update_record',
label: 'Auto Approve',
- config: { object: 'order', fields: { status: 'approved' } },
+ config: { objectName: 'order', fields: { status: 'approved' } },
},
{
id: 'request_approval',
@@ -166,11 +167,11 @@ Each node performs a specific action in the flow.
type: 'create_record',
label: 'Create Follow-up Task',
config: {
- object: 'task',
+ objectName: 'task',
fields: {
title: 'Follow up on {record.name}',
assignee: '{record.owner}',
- due_date: 'TODAY() + 7',
+ due_date: '{TODAY() + 7}', // braces required — without them this writes the literal text
},
},
}
@@ -546,6 +547,84 @@ The plugin is a **soft dependency** on `metadata` — it tolerates running
without `MetadataPlugin` and it logs (not throws) on per-flow registration
failures so one broken flow does not abort startup.
+## Expressions in flows
+
+A flow mixes **three expression dialects**, and using the wrong one is the
+single most common way a flow silently misbehaves. Which dialect applies is
+decided by *where* the expression sits — not by what it looks like:
+
+| Where | Dialect | Write it like | Bindings |
+|:---|:---|:---|:---|
+| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*` |
+| Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above |
+| Decision-node `conditions[].expression` | **Template compare** (braces required) | `{order_amount} > 10000` | flow variables by name, in `{…}` |
+| Field values in `create_record` / `update_record` | **Interpolation** (braces required) | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, `{TODAY()}`, `{TODAY() + 90}` (whole days) |
+
+
+**The two failure modes to memorize:**
+
+1. **Braces missing in a decision expression** — `'order_amount > 10000'` isn't
+ evaluated as a variable at all. It compares the *string* `"order_amount"`
+ against `"10000"`, which is **always true**, so the flow always takes the
+ first branch and never tells you. Write `'{order_amount} > 10000'`.
+2. **Braces missing in a field value** — `due_date: 'TODAY() + 7'` writes the
+ literal text `TODAY() + 7` into the field. Write `'{TODAY() + 7}'`.
+
+The mirror mistake is putting braces *into* a CEL condition
+(`'{record.amount} > 500'`) — CEL conditions fail loudly rather than silently,
+with an error that tells you to drop the braces.
+
+
+CEL conditions that fail to evaluate raise an error and stop the run — they
+never quietly pass. See [Formulas](/docs/data-modeling/formulas) for the CEL
+language itself.
+
+
+**A boolean gotcha:** SQLite/libSQL store booleans as `0`/`1`, and CEL's `1 !=
+true`. Compare against the stored shape (`record.done == 1`) or normalize the
+value before the condition.
+
+
+## Run a flow via API
+
+Flows of any type can be launched over HTTP — this is what an external system,
+a scheduled job outside the platform, or the Console's test runner uses:
+
+```bash
+curl -b cookies.txt -X POST \
+ https://your-app.example.com/api/v1/automation/order_approval/trigger \
+ -H "Content-Type: application/json" \
+ -d '{ "recordId": "ord_123", "objectName": "order", "params": { "priority": "high" } }'
+# → { "success": true, "data": { ... }, "meta": { ... } }
+```
+
+| Endpoint | Purpose |
+|:---|:---|
+| `POST /api/v1/automation/:name/trigger` | Start a flow (canonical) |
+| `GET /api/v1/automation/:name/runs` | List runs (`?limit`, `?cursor`) |
+| `GET /api/v1/automation/:name/runs/:runId` | One run's detail (404 `Execution not found`) |
+| `POST /api/v1/automation/:name/runs/:runId/resume` | Resume a paused run — body `{ inputs, output, branchLabel }` |
+| `GET /api/v1/automation/:name/runs/:runId/screen` | The pending screen of a screen-flow run |
+
+**Passing inputs.** Declare variables with `isInput: true`, then send them in
+`params` under the same names — only declared inputs are bound. `recordId` and
+`objectName` are lifted into `params` for you (plus an `Id` alias),
+and `event` defaults to `'manual'`.
+
+**Identity.** The caller's identity (user, positions, permissions, tenant) is
+forwarded into the run, so a `runAs: 'user'` flow executes under that caller's
+row-level security. See [API Authentication](/docs/api#authentication) for
+credentials.
+
+
+**`success: true` doesn't always mean "it ran".** If the start condition isn't
+met, the response is a *successful* skip:
+`{ "success": true, "data": { "skipped": true, "reason": "condition_not_met" } }`.
+A self-triggering flow caught by the loop guard reports
+`reason: "reentrancy_loop_guard"` the same way. Check `skipped` before assuming
+the work happened.
+
+
## Console Flow Viewer & Test Runner
Every flow can surface in the Console metadata browser under `/_console/`.
@@ -558,8 +637,9 @@ the flow viewer plugin is installed:
| **Run** | `FlowTestRunner` | Auto-generates a form for every `isInput: true` variable (with type-aware coercion for `number` / `boolean` / `object` / `list`), executes the flow against the per-project kernel, and shows the returned outputs + run id |
| **Runs** | `FlowRunsPanel` | Lists historical executions for the selected flow with status, duration, and a deep-link to the run record |
-The runner posts to the standard automation execute endpoint, scoped to
-the active project. All three components participate in the Studio
+The runner posts to the same
+[`POST /api/v1/automation/:name/trigger`](#run-a-flow-via-api) endpoint,
+scoped to the active project. All three components participate in the Studio
authentication / project-scope context, so they work identically in
single-project mode and in cloud mode.
diff --git a/content/docs/permissions/meta.json b/content/docs/permissions/meta.json
index 4da7101f22..380e12fca9 100644
--- a/content/docs/permissions/meta.json
+++ b/content/docs/permissions/meta.json
@@ -12,6 +12,7 @@
"positions",
"delegated-administration",
"sharing-rules",
+ "rls",
"field-level-security",
"attachments-access",
"permission-metadata",
diff --git a/content/docs/permissions/rls.mdx b/content/docs/permissions/rls.mdx
new file mode 100644
index 0000000000..ea81d575b7
--- /dev/null
+++ b/content/docs/permissions/rls.mdx
@@ -0,0 +1,188 @@
+---
+title: Row-Level Security (RLS)
+description: Declarative per-row policies compiled into query filters — the grammar, the context variables, how policies compose, and the fail-closed contract.
+---
+
+# Row-Level Security (RLS)
+
+RLS answers a question the other layers can't: *which rows* of an object may
+this user touch? Object permissions say "can read tasks"; the OWD baseline says
+"tasks are private"; RLS says **"only the tasks assigned to you"** — as a
+declarative policy, compiled into the query.
+
+Two properties define how it behaves:
+
+- **It's a filter, not a row hook.** Policies compile to a data filter that is
+ pushed down into the query, so a restricted user's list is *narrower* — they
+ don't get denied, they get fewer rows.
+- **It's fail-closed.** A policy that can't compile denies everything rather
+ than admitting anything. See [the contract](#the-fail-closed-contract).
+
+
+RLS is the expert escape hatch. Reach for it when
+[sharing rules](/docs/permissions/sharing-rules), the
+[OWD baseline](/docs/permissions/permissions-matrix), and
+[scope depth](/docs/permissions/positions) can't express the rule — not before.
+
+
+## Anatomy of a policy
+
+Policies live on a permission set under `rowLevelSecurity`:
+
+```typescript
+import { definePermissionSet } from '@objectstack/spec';
+
+export const ContributorAccess = definePermissionSet({
+ name: 'contributor',
+ label: 'Contributor',
+ objects: {
+ showcase_task: { allowRead: true, allowEdit: true },
+ },
+ rowLevelSecurity: [
+ {
+ name: 'task_own_rows',
+ label: 'Own Tasks Only',
+ object: 'showcase_task',
+ operation: 'select',
+ using: 'assignee == current_user.email',
+ positions: ['contributor'],
+ enabled: true,
+ priority: 10,
+ },
+ ],
+});
+```
+
+| Property | Type | Notes |
+|:---|:---|:---|
+| `name` | `string` | snake_case identifier |
+| `label` | `string` | Human-readable name |
+| `object` | `string` | Target object — or `'*'` to apply to every object |
+| `operation` | `'select' \| 'insert' \| 'update' \| 'delete' \| 'all'` | Which operation the policy guards |
+| `using` | `string` | Predicate for rows the user may **see / act on** (compiled into the query filter) |
+| `check` | `string` | Predicate rows must satisfy **after a write**. Omit it and `using` is reused |
+| `positions` | `string[]` | Which positions the policy applies to. Omit = everyone |
+| `enabled` | `boolean` | Default `true` |
+| `priority` | `number` | Default `0` |
+
+At least one of `using` / `check` is required.
+
+**Choosing `operation`:** `select` narrows reads (the common case);
+`insert` / `update` / `delete` guard the matching write; `all` applies to
+everything. Internally `find` / `findOne` / `count` / `aggregate` all map to
+`select`.
+
+## The expression grammar
+
+This is the part that bites. RLS expressions are **not** general CEL — only
+four forms compile:
+
+| Form | Example |
+|:---|:---|
+| Column equals a context property | `created_by == current_user.id` |
+| Column equals a literal | `status == 'active'` |
+| Column is in a context array | `owner_id IN (current_user.org_user_ids)` |
+| Always true | `1 = 1` |
+
+Everything else is rejected, including `AND` / `OR` / `NOT`, comparison
+operators other than equality (`>`, `<`, `!=`), `IS NULL`, `NOT IN`,
+`LIKE` / `ILIKE`, regex, `ANY` / `ALL`, subqueries, and time functions
+(`NOW()`, `CURRENT_DATE`).
+
+
+**There is no `AND` / `OR` inside a policy — use multiple policies instead.**
+Policies on the same object compose with **OR** (see below), which covers
+"owner *or* team member". For "owner *and* active", model the second condition
+in the data (a dedicated column or a narrower context array) rather than
+reaching for a compound expression the compiler will reject.
+
+
+## Context variables
+
+Predicates reference the acting user through `current_user`, and record fields
+as **bare column names** (no `record.` prefix — that's the UI/flow convention,
+not this one):
+
+| Variable | Source |
+|:---|:---|
+| `current_user.id` | The acting user's id |
+| `current_user.organization_id` | The acting tenant |
+| `current_user.positions` | The user's positions (array) |
+| `current_user.org_user_ids` | User ids in the user's org (array) |
+| `current_user.email` | The user's email |
+
+
+**`current_user.name` is deliberately not exposed.** Names aren't unique — a
+policy keyed on a display name would hand a second "John Smith" someone else's
+rows. Match on `id` or `email`, which are.
+
+
+Deployments can inject additional membership sets (team ids, territory codes)
+into the evaluation context, which lets policies express set membership without
+subqueries the grammar doesn't allow.
+
+## How policies compose
+
+```
+Layer 0 (tenant isolation) ─┐
+ ├── AND ──► the query's final filter
+Layer 1 (business RLS) ─┘
+ policy A OR policy B OR …
+```
+
+- **Tenant isolation is a separate layer that always applies first** and is
+ never OR-ed away by a business policy.
+- **Multiple policies on the same object are OR-ed** — a row is admitted if it
+ matches *any* applicable policy. This is the intended way to express
+ alternatives.
+- The full evaluation pipeline around RLS is: object CRUD → FLS → OWD baseline →
+ depth → sharing → RLS. RLS narrows what the earlier layers already allowed;
+ it never widens.
+- A superuser bypasses business policies but **not** tenant isolation —
+ crossing tenants requires a genuine platform admin.
+- Children of a master-detail parent with `sharingModel: 'controlled_by_parent'`
+ derive access from the master record instead.
+
+## The fail-closed contract
+
+Four ways a policy denies rather than leaks:
+
+1. A policy exists but **every** applicable expression fails to compile → a
+ deny-everything filter.
+2. A referenced context variable is missing, null, or an empty array → that
+ policy drops out (it cannot match).
+3. A policy references a column the object doesn't have → deny.
+4. `check` is omitted → `using` is reused for writes, never "anything goes".
+
+
+**The one non-obvious case: no applicable policy means *no restriction*, not
+"deny".** RLS only narrows what other layers granted — it is not what stops an
+un-permissioned user. If a user sees rows you expected RLS to hide, check
+whether any policy actually applies to their positions and the operation.
+
+
+## Verify it
+
+Don't guess — ask the runtime:
+
+```bash
+curl -b cookies.txt \
+ "https://your-app.example.com/api/v1/security/explain?object=showcase_task&operation=select&userId=usr_123"
+```
+
+The response walks the layers (`tenant_isolation`, `object_crud`, `fls`,
+`owd_baseline`, `depth`, `sharing`, `rls`) with a `verdict` per layer —
+`grants`, `denies`, `narrows`, `widens`, `neutral`, or `not_applicable` — and
+tags each with `kernelTier` (`layer_0_tenant` vs `layer_1_business`). An RLS
+policy that fired shows up as `narrows` on the `rls` layer; per-record
+explanations mark rows `admitted` / `excluded`.
+
+Explaining another user's access requires `manage_users` or delegated-admin
+rights. Pair it with Impersonate in the Console to see the same rows they'd see.
+
+## Related
+
+- [Authorization Model](/docs/permissions/authorization) — how all the layers fit together
+- [Sharing Rules](/docs/permissions/sharing-rules) — the declarative layer to try first
+- [Explain](/docs/permissions/explain) — the verdict vocabulary in full
+- [Access Recipes](/docs/permissions/access-recipes) — requirement → layer mapping
diff --git a/content/docs/ui/meta.json b/content/docs/ui/meta.json
index 2385275725..f768aa09e4 100644
--- a/content/docs/ui/meta.json
+++ b/content/docs/ui/meta.json
@@ -8,6 +8,7 @@
"views",
"actions",
"dashboards",
+ "translations",
"forms",
"doc-pages",
"setup-app",
diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx
new file mode 100644
index 0000000000..9d0d31c13c
--- /dev/null
+++ b/content/docs/ui/translations.mdx
@@ -0,0 +1,144 @@
+---
+title: Translations
+description: Labels and UI text as metadata — one bundle per locale, resolved per request, with CLI tooling to draft and to gate coverage in CI.
+---
+
+# Translations
+
+Every label in an ObjectStack app — object and field names, picklist options,
+view titles, action buttons, app navigation — is metadata, not a string baked
+into a component. Adding a language means adding a **bundle**, not touching the
+app.
+
+
+**Single-locale apps need none of this.** `translations` and `i18n` are both
+optional; with no bundle the runtime falls back to the labels declared on your
+metadata. Built-in system fields (`owner_id`, `created_at`, …) carry their own
+labels either way.
+
+
+## Your first bundle
+
+A bundle is a **map of locale → translations**, registered on your stack:
+
+```typescript title="src/translations/crm.translation.ts"
+import { defineTranslationBundle } from '@objectstack/spec';
+
+export const CrmTranslationBundle = defineTranslationBundle({
+ en: {
+ objects: {
+ crm_account: {
+ label: 'Account',
+ pluralLabel: 'Accounts',
+ fields: {
+ name: { label: 'Account Name' },
+ industry: { label: 'Industry' },
+ },
+ },
+ },
+ },
+ 'zh-CN': {
+ objects: {
+ crm_account: {
+ label: '客户',
+ pluralLabel: '客户',
+ fields: {
+ name: { label: '客户名称' },
+ industry: { label: '行业' },
+ },
+ },
+ },
+ },
+});
+```
+
+```typescript title="objectstack.config.ts"
+export default defineStack({
+ // ...
+ translations: [CrmTranslationBundle],
+ i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
+});
+```
+
+## What you can translate
+
+| Surface | Where it lives in the bundle |
+|:---|:---|
+| Object label / plural / description | `objects..label` / `pluralLabel` / `description` |
+| Field labels, help text, placeholders | `objects..fields..label` / `help` / `placeholder` |
+| Picklist option labels | `objects..fields..options.` |
+| View titles, descriptions, empty states | `objects.._views.` |
+| Action labels, confirm text, success messages | `objects.._actions.` |
+| Form sections | `objects.._sections.` |
+| App navigation | `apps..navigation..label` |
+| Dashboards and widgets | `dashboards.` |
+| Global actions, settings, messages | `globalActions`, `settings`, `messages` |
+
+The metadata types resolved per request are **object, field, view, action, app,
+and dashboard**.
+
+## How a locale is chosen
+
+Per request, in this order:
+
+1. The `Accept-Language` header (the client SDK sends it; `setLocale()` sets it)
+2. A `?locale=` query parameter
+3. The stack's `defaultLocale`
+
+Within the bundle, matching walks: exact (`zh-CN`) → case-insensitive →
+**base language** (`zh-CN` → `zh`) → variant expansion (`zh` → `zh-CN`). If
+nothing matches, the chain falls back to `en`, and finally to the literal label
+on the metadata. **Translation lookup never throws** — a missing string
+degrades to the next best text.
+
+Resolved labels are served straight from the REST metadata endpoints (the
+locale is part of the ETag), so the Console and any SDUI client get translated
+metadata without doing lookups themselves.
+
+## Organizing the files
+
+`i18n.fileOrganization` picks the layout:
+
+- **`per_locale`** (default) — one file per language, combined in the bundle.
+ This is what the Todo example does (`en`, `zh-CN`, `ja-JP`).
+- **`bundled`** — every locale in one file, like the CRM example above. Fine
+ for two locales; unwieldy past that.
+- **`per_namespace`** — split by module.
+
+## Draft with the CLI, gate in CI
+
+```bash
+# Scaffold entries for everything translatable that isn't yet
+npx os i18n extract --locales zh-CN --fill todo --out src/translations
+
+# Report coverage; fail the build when it slips
+npx os i18n check --strict --threshold 95
+```
+
+`os i18n check` exits non-zero on violations, so it works as a CI gate.
+A missing string in the **default** locale is an error; missing strings in
+other locales are warnings until you set `--strict` / `--threshold`. The Todo
+example ships a completeness test alongside its bundles — worth copying.
+
+## Current boundaries
+
+Honest limits worth knowing before you plan around them:
+
+- **`validationMessages` has no runtime consumer today.** The schema accepts it
+ and the coverage tooling counts it, but nothing reads it back — validation
+ errors are not translated through bundles yet.
+- **`messageFormat: 'icu'` is declared but not implemented** — plural/gender
+ formatting isn't available; `simple` is what runs.
+- **Two bundle shapes exist.** File-authored bundles use the `objects.`
+ shape documented here, which is what the resolver reads. The `translation`
+ metadata type authored at runtime uses an object-first (`o.