Skip to content

Commit 25a19be

Browse files
os-zhuangclaude
andauthored
docs: P1 wave 2 — RLS, translations, flow expressions/API, approval lifecycle (#3113)
Four audit gaps closed, all grounded in source research. Three of them corrected the audit's own assumptions — noted below. New: permissions/rls.mdx - The audit believed three pages contradicted each other on `operation`. They don't: 'select' | 'insert' | 'update' | 'delete' | 'all' are all valid. The real undocumented landmine is the grammar — only four forms compile (col == current_user.prop, col == literal, col IN (array), 1 = 1); AND/OR/comparisons/IS NULL/LIKE/subqueries/NOW() are rejected, and a non-compiling policy denies everything. - Also documents: bare column names (no record. prefix), the context variable set (and why current_user.name is withheld), Layer 0 AND Layer 1 with policies OR-ing inside a layer, the four fail-closed paths, "no applicable policy = no restriction" (the trap), and verification via /security/explain. New: ui/translations.mdx — the one landing-promise capability with zero guide coverage. Bundle shape, registration, translatable surfaces, locale resolution (Accept-Language → ?locale= → default; zh-CN → zh → en → literal), file organization, os i18n extract/check as a CI gate, and honest limits (validationMessages has no consumer, ICU unimplemented, file vs runtime bundle shapes unbridged). automation/flows.mdx - New "Expressions in flows": the three dialects are real and differ by position — start/edge conditions are bare CEL, decision expressions need {braces}, field values are interpolation. Fixed two example bugs this exposed: `expression: 'order_amount > 10000'` (no braces) degrades to a string compare that is ALWAYS TRUE, and `due_date: 'TODAY() + 7'` writes the literal text. Also `object:` → `objectName:` (canonical; the alias is publish-gate rejected). - New "Run a flow via API": the endpoint the page previously referenced without ever naming (POST /api/v1/automation/:name/trigger), plus runs/resume/screen, input binding via isInput, identity forwarding, and the success:true + skipped:true trap. automation/approvals.mdx — end-to-end lifecycle Steps walkthrough. The audit called the approver queue missing; it exists (sys_approval_request my_pending view + Setup nav + GET /approvals/requests). The actual gap is that opening a request notifies nobody — now stated with the two workarounds. Adds approve/reject endpoints, the unanimous not-finalized-yet trap, type:'role' ≠ position, field mirroring, real escalation (with its job-service precondition), and the error table. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a34b11c commit 25a19be

6 files changed

Lines changed: 528 additions & 7 deletions

File tree

content/docs/automation/approvals.mdx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,113 @@ export const opportunityApproval: Flow = {
114114
For multi-step approvals, chain multiple `approval` nodes. For parallel
115115
approvals, see the aggregating-node pattern in [Flow Metadata](/docs/automation/flows).
116116

117+
## The full lifecycle — submit to field change
118+
119+
What actually happens between "the flow hits the approval node" and "the record
120+
says approved":
121+
122+
<Steps>
123+
124+
### The request opens, the run pauses
125+
126+
The node writes a `sys_approval_request` row: `status: 'pending'`,
127+
`pending_approvers` (the resolved approver list), and — if you declared
128+
`approvalStatusField` — it mirrors `'pending'` onto your record's field. The
129+
record is **locked** against edits while pending (`lockRecord`, default `true`),
130+
and the flow run parks until a decision arrives.
131+
132+
Only `approvers` is required on the node; everything else has a default
133+
(`behavior: 'first_response'`, `lockRecord: true`, `maxRevisions: 3`).
134+
135+
<Callout type="warn">
136+
**`type: 'role'` is not a position.** In an approver entry, `role` means the
137+
better-auth membership tier (`owner` / `admin` / `member`). Naming a business
138+
role like `sales_manager` there matches nobody and the request silently has no
139+
approvers — use `{ type: 'position', value: 'sales_manager' }`.
140+
</Callout>
141+
142+
### The approver finds it in their queue
143+
144+
Requests land in **Setup → Approvals → Requests**, whose `my_pending` view
145+
filters to `status = pending` and `pending_approvers` containing the current
146+
user. Programmatically:
147+
148+
```bash
149+
curl -b cookies.txt \
150+
"https://your-app.example.com/api/v1/approvals/requests?status=pending&approverId=usr_123"
151+
```
152+
153+
`approverId` accepts a user id, an email, or `role:<r>` — and takes several
154+
values (comma-separated or repeated) to cover a person's identities in one call.
155+
Other filters: `status`, `object`, `recordId`, `submitterId`, `q`, `limit`,
156+
`offset`.
157+
158+
<Callout type="warn">
159+
**Opening a request notifies nobody.** There is no built-in "you have an
160+
approval waiting" message today — an approver only discovers it by looking at
161+
the queue. If people need to be told, do one of: add a `notify` node next to
162+
the approval node in the flow (the practical answer), or install a messaging
163+
service and drive `remind()`. Reminder, escalation, return, and reassignment
164+
*do* publish notification topics — the initial request doesn't.
165+
</Callout>
166+
167+
### The decision
168+
169+
```bash
170+
curl -b cookies.txt -X POST \
171+
https://your-app.example.com/api/v1/approvals/requests/req_456/approve \
172+
-H "Content-Type: application/json" \
173+
-d '{ "comment": "Within budget." }'
174+
# POST .../reject for the other direction; body: { actorId?, comment? }
175+
```
176+
177+
`actorId` defaults to the caller. The actor **must** be in `pending_approvers`
178+
or the call returns 403 (`FORBIDDEN: actor '…' is not a pending approver`); a
179+
request that isn't pending returns 409 (`INVALID_STATE`). Always go through
180+
these endpoints — never resume the flow run directly.
181+
182+
<Callout type="info">
183+
**With `behavior: 'unanimous'`, an approve is not the end.** Every approver but
184+
the last only trims `pending_approvers`; the request stays `pending`
185+
(`finalized: false`) and the flow stays parked. Only the final approval
186+
finalizes it.
187+
</Callout>
188+
189+
### The record changes and the flow resumes
190+
191+
On finalization the request row gets `status: 'approved'` (or `'rejected'`),
192+
`pending_approvers: null`, and `completed_at`; your `approvalStatusField` is
193+
mirrored to the same value, and the record unlocks. The parked run resumes down
194+
the `approve` or `reject` branch.
195+
196+
Statuses in full: `pending`, `approved`, `rejected`, `recalled`, `returned`.
197+
198+
</Steps>
199+
200+
### Timeouts and escalation
201+
202+
`escalation` is real, not decorative: set `enabled: true` and `timeoutHours`,
203+
and pick an `action``notify` (default), `reassign`, `auto_approve`, or
204+
`auto_reject`. Auto decisions run through the normal decide path, so the flow
205+
resumes exactly as if a human had clicked. Every escalation writes an audit row.
206+
207+
<Callout type="warn">
208+
**Escalation needs the job service.** The plugin sweeps pending requests on an
209+
internal timer (~5 min). Without a `job` service registered, SLA timers are
210+
**display-only** and no escalation ever fires.
211+
</Callout>
212+
213+
### Error codes
214+
215+
| Code | HTTP | Meaning |
216+
|:---|:---|:---|
217+
| `VALIDATION_FAILED` | 400 | Malformed request |
218+
| `FORBIDDEN` | 403 | Actor isn't a pending approver |
219+
| `REQUEST_NOT_FOUND` | 404 | No such request |
220+
| `DUPLICATE_REQUEST` | 409 | A pending request already exists for this record |
221+
| `INVALID_STATE` | 409 | The request is no longer pending |
222+
| `THROTTLED` | 429 | Rate limited |
223+
117224
### Best practices
118225

119226
**DO:**

content/docs/automation/flows.mdx

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,18 @@ const approvalFlow = {
3131
type: 'decision',
3232
label: 'Check Amount',
3333
config: {
34+
// decision expressions use {braces} around variables — see Expressions in flows
3435
conditions: [
35-
{ label: 'High Value', expression: 'order_amount > 10000' },
36-
{ label: 'Standard', expression: 'order_amount <= 10000' },
36+
{ label: 'High Value', expression: '{order_amount} > 10000' },
37+
{ label: 'Standard', expression: '{order_amount} <= 10000' },
3738
],
3839
},
3940
},
4041
{
4142
id: 'auto_approve',
4243
type: 'update_record',
4344
label: 'Auto Approve',
44-
config: { object: 'order', fields: { status: 'approved' } },
45+
config: { objectName: 'order', fields: { status: 'approved' } },
4546
},
4647
{
4748
id: 'request_approval',
@@ -166,11 +167,11 @@ Each node performs a specific action in the flow.
166167
type: 'create_record',
167168
label: 'Create Follow-up Task',
168169
config: {
169-
object: 'task',
170+
objectName: 'task',
170171
fields: {
171172
title: 'Follow up on {record.name}',
172173
assignee: '{record.owner}',
173-
due_date: 'TODAY() + 7',
174+
due_date: '{TODAY() + 7}', // braces required — without them this writes the literal text
174175
},
175176
},
176177
}
@@ -546,6 +547,84 @@ The plugin is a **soft dependency** on `metadata` — it tolerates running
546547
without `MetadataPlugin` and it logs (not throws) on per-flow registration
547548
failures so one broken flow does not abort startup.
548549

550+
## Expressions in flows
551+
552+
A flow mixes **three expression dialects**, and using the wrong one is the
553+
single most common way a flow silently misbehaves. Which dialect applies is
554+
decided by *where* the expression sits — not by what it looks like:
555+
556+
| Where | Dialect | Write it like | Bindings |
557+
|:---|:---|:---|:---|
558+
| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*` |
559+
| Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above |
560+
| Decision-node `conditions[].expression` | **Template compare** (braces required) | `{order_amount} > 10000` | flow variables by name, in `{…}` |
561+
| 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) |
562+
563+
<Callout type="warn">
564+
**The two failure modes to memorize:**
565+
566+
1. **Braces missing in a decision expression**`'order_amount > 10000'` isn't
567+
evaluated as a variable at all. It compares the *string* `"order_amount"`
568+
against `"10000"`, which is **always true**, so the flow always takes the
569+
first branch and never tells you. Write `'{order_amount} > 10000'`.
570+
2. **Braces missing in a field value**`due_date: 'TODAY() + 7'` writes the
571+
literal text `TODAY() + 7` into the field. Write `'{TODAY() + 7}'`.
572+
573+
The mirror mistake is putting braces *into* a CEL condition
574+
(`'{record.amount} > 500'`) — CEL conditions fail loudly rather than silently,
575+
with an error that tells you to drop the braces.
576+
</Callout>
577+
578+
CEL conditions that fail to evaluate raise an error and stop the run — they
579+
never quietly pass. See [Formulas](/docs/data-modeling/formulas) for the CEL
580+
language itself.
581+
582+
<Callout type="info">
583+
**A boolean gotcha:** SQLite/libSQL store booleans as `0`/`1`, and CEL's `1 !=
584+
true`. Compare against the stored shape (`record.done == 1`) or normalize the
585+
value before the condition.
586+
</Callout>
587+
588+
## Run a flow via API
589+
590+
Flows of any type can be launched over HTTP — this is what an external system,
591+
a scheduled job outside the platform, or the Console's test runner uses:
592+
593+
```bash
594+
curl -b cookies.txt -X POST \
595+
https://your-app.example.com/api/v1/automation/order_approval/trigger \
596+
-H "Content-Type: application/json" \
597+
-d '{ "recordId": "ord_123", "objectName": "order", "params": { "priority": "high" } }'
598+
# → { "success": true, "data": { ... }, "meta": { ... } }
599+
```
600+
601+
| Endpoint | Purpose |
602+
|:---|:---|
603+
| `POST /api/v1/automation/:name/trigger` | Start a flow (canonical) |
604+
| `GET /api/v1/automation/:name/runs` | List runs (`?limit`, `?cursor`) |
605+
| `GET /api/v1/automation/:name/runs/:runId` | One run's detail (404 `Execution not found`) |
606+
| `POST /api/v1/automation/:name/runs/:runId/resume` | Resume a paused run — body `{ inputs, output, branchLabel }` |
607+
| `GET /api/v1/automation/:name/runs/:runId/screen` | The pending screen of a screen-flow run |
608+
609+
**Passing inputs.** Declare variables with `isInput: true`, then send them in
610+
`params` under the same names — only declared inputs are bound. `recordId` and
611+
`objectName` are lifted into `params` for you (plus an `<objectName>Id` alias),
612+
and `event` defaults to `'manual'`.
613+
614+
**Identity.** The caller's identity (user, positions, permissions, tenant) is
615+
forwarded into the run, so a `runAs: 'user'` flow executes under that caller's
616+
row-level security. See [API Authentication](/docs/api#authentication) for
617+
credentials.
618+
619+
<Callout type="warn">
620+
**`success: true` doesn't always mean "it ran".** If the start condition isn't
621+
met, the response is a *successful* skip:
622+
`{ "success": true, "data": { "skipped": true, "reason": "condition_not_met" } }`.
623+
A self-triggering flow caught by the loop guard reports
624+
`reason: "reentrancy_loop_guard"` the same way. Check `skipped` before assuming
625+
the work happened.
626+
</Callout>
627+
549628
## Console Flow Viewer & Test Runner
550629

551630
Every flow can surface in the Console metadata browser under `/_console/`.
@@ -558,8 +637,9 @@ the flow viewer plugin is installed:
558637
| **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 |
559638
| **Runs** | `FlowRunsPanel` | Lists historical executions for the selected flow with status, duration, and a deep-link to the run record |
560639

561-
The runner posts to the standard automation execute endpoint, scoped to
562-
the active project. All three components participate in the Studio
640+
The runner posts to the same
641+
[`POST /api/v1/automation/:name/trigger`](#run-a-flow-via-api) endpoint,
642+
scoped to the active project. All three components participate in the Studio
563643
authentication / project-scope context, so they work identically in
564644
single-project mode and in cloud mode.
565645

content/docs/permissions/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"positions",
1313
"delegated-administration",
1414
"sharing-rules",
15+
"rls",
1516
"field-level-security",
1617
"attachments-access",
1718
"permission-metadata",

0 commit comments

Comments
 (0)