Skip to content

Commit d4ab084

Browse files
os-zhuangclaude
andauthored
docs(guides): add scenario cookbook (Solutions) + create-vs-edit-form example (#2500)
Adds a goal-oriented "Solutions" docs section that answers "how do I solve business problem X" across objects/fields/forms/views/permissions/automation — the connective tissue missing between the per-feature guides and the schema references. Each recipe links to a runnable example and its decision record, and doubles as the few-shot corpus for AI authoring (ADR-0047 §3.5). Recipes: - create-vs-edit-form: derive both forms from one intent-tagged field set; hand-shape create only on real layout/flow divergence - field-grouping-and-order: field.group (semantic) vs form sections (layout) vs grid grouping (row aggregation) - role-based-interfaces: consumer App/page vs builder Studio surfaces - data-automation-interface-access: CRUD/FLS/RLS/capabilities/runAs mapping - public-data-collection: anonymous form with declaration-derived authz - approval-workflow: configure capability + runAs run-identity Showcase example backing recipe 1: - showcase_contact object: flat, grouped, intent-tagged field set - contact views: full grouped edit form + sparse formViews.create override bound via addRecord.formView Wires solutions into guides meta.json + index. Claude-Session: https://claude.ai/code/session_01NvGeDWAo3rrjc3CQ8vmAac Co-authored-by: Claude <noreply@anthropic.com>
1 parent c9b9b14 commit d4ab084

14 files changed

Lines changed: 723 additions & 1 deletion

content/docs/guides/index.mdx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ title: Guides
33
description: Task-oriented tutorials for building applications with ObjectStack
44
---
55

6-
import { Database, Workflow, Shield, Bot, Globe, Wrench, BookMarked, FileText, Brain } from 'lucide-react';
6+
import { Database, Workflow, Shield, Bot, Globe, Wrench, BookMarked, FileText, Brain, Lightbulb } from 'lucide-react';
77

88
# Developer Guides
99

@@ -13,6 +13,19 @@ Practical, task-oriented guides covering the full development workflow. Each gui
1313
**New here?** Complete the [Quick Start](/docs/getting-started/quick-start) first, then return here to go deeper.
1414
</Callout>
1515

16+
## Solutions (Scenario Cookbook)
17+
18+
Goal-oriented recipes: given a concrete business scenario, the recommended way to solve it across objects, fields, forms, views, permissions and automation. The rest of the guides explain *how a feature works*; these explain *how to solve a problem*.
19+
20+
<Cards>
21+
<Card
22+
icon={<Lightbulb />}
23+
title="Scenario Cookbook"
24+
href="/docs/guides/solutions"
25+
description="Create-vs-edit forms, field grouping, role-based interfaces, access control, public forms, approvals — each with a runnable example."
26+
/>
27+
</Cards>
28+
1629
## Foundations
1730

1831
<Cards>

content/docs/guides/meta.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"root": true,
55
"pages": [
66
"index",
7+
"---Solutions---",
8+
"solutions",
79
"---Foundations---",
810
"packages",
911
"metadata",
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
title: Approval workflow
3+
description: Route a record for sign-off — who can configure the automation, and the run-identity decision that keeps an approval flow from quietly bypassing row-level security.
4+
---
5+
6+
# Approval workflow
7+
8+
## Scenario
9+
10+
> A record (an invoice, a discount, a leave request) must be **routed for approval** before it proceeds. Who is allowed to build that automation, and how do I make sure it runs safely?
11+
12+
## Recommended solution
13+
14+
An approval is a **flow** with an **approval node**. Two access decisions matter as much as the routing itself.
15+
16+
### 1. Who can configure it
17+
18+
Authoring flows/automations is a builder capability — it needs `manage_metadata` (typically Studio users). End users **submit** records and **act on** approval requests, but they do not edit the automation. Keep the automation surfaces out of consumer apps (see [role-based interfaces](/docs/guides/solutions/role-based-interfaces)).
19+
20+
### 2. As whom does it run — the safety decision
21+
22+
A flow declares `runAs` (ADR-0049), and for approvals this is the decision that keeps it safe:
23+
24+
- `runAs: 'user'` (default) — the flow's data operations run as the **submitter**, respecting their RLS. Good when the flow only touches records the submitter can already see.
25+
- `runAs: 'system'`**elevated**, bypasses RLS. Needed when the flow must read/write records the submitter can't (e.g. post to a ledger, notify an approver who owns rows the submitter can't see). Declare it **explicitly** so the elevation is visible, not accidental.
26+
27+
A schedule-triggered escalation has no triggering user — so it must be `system` to act at all.
28+
29+
### 3. The approval node
30+
31+
The node declares **who approves** (a named user, a role, the submitter's manager, …) and what happens on approve / reject / escalate. The authoritative shape is [`ApprovalNodeConfig` / `ApproverType`](/docs/references/automation/approval); a flow strings the trigger, the approval node, and the post-decision branches together.
32+
33+
```ts
34+
// Illustrative — see the Approval reference for the exact node schema.
35+
defineFlow({
36+
name: 'invoice_approval',
37+
runAs: 'user', // submitter's RLS unless a step needs more
38+
trigger: { object: 'invoice', on: 'create' },
39+
nodes: [
40+
{ type: 'approval', approver: { type: 'role', role: 'finance_manager' },
41+
onApprove: 'mark_approved', onReject: 'mark_rejected' },
42+
],
43+
});
44+
```
45+
46+
Approving is itself a gated action — model "may approve" as a capability (`approve_invoice`) the approver role holds, and gate the approve action's `requiredPermissions` on it so the gate is enforced on **both** the UI and the server (ADR-0066 D4).
47+
48+
## Why
49+
50+
Separating *who configures* from *who approves* from *as whom it runs* is the same capability/assignment/requirement decoupling as the rest of authorization (ADR-0066). The `runAs` default of `user` means an approval flow can't silently grant the submitter cross-tenant or cross-owner reach — elevation is opt-in and auditable. That is the safe-by-default posture: the common case respects RLS; the elevated case is explicit.
51+
52+
## Runnable example
53+
54+
- Flows: [`examples/app-showcase/src/flows`](https://github.com/objectstack-ai/framework/tree/main/examples/app-showcase/src/flows).
55+
- Approval schema: [`packages/spec/src/automation/approval.zod.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/spec/src/automation/approval.zod.ts).
56+
57+
## Anti-patterns
58+
59+
- **`runAs: 'system'` everywhere** "to make it work". Default to `user`; elevate a single step only when it genuinely needs to bypass RLS.
60+
- **Exposing automation config to approvers/submitters.** Configuring the flow is a `manage_metadata` (builder) concern; acting on a request is not.
61+
- **Gating "Approve" only in the UI.** Make `approve_*` a capability and gate the action so the server enforces it too.
62+
63+
## See also
64+
65+
- Decision records: **ADR-0049** (`runAs`), **ADR-0066** (unified authorization).
66+
- Guide: [Business Logic](/docs/guides/business-logic) (Flows, Approval Nodes); [Who can see data / automation / interface](/docs/guides/solutions/data-automation-interface-access).
67+
- Reference: [Flow](/docs/references/automation/flow), [Approval](/docs/references/automation/approval).
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
---
2+
title: Create form ≠ edit form
3+
description: The new-record form asks 5 fields; the full edit form shows 40 grouped into sections. Derive both from one flat field set; only hand-shape the create form when layout or flow genuinely diverges.
4+
---
5+
6+
# Create form ≠ edit form
7+
8+
## Scenario
9+
10+
> The form for **creating** a record should ask only a handful of fields (the essentials). The form for **editing** an existing record shows the full record, grouped into sections. How do I model this without maintaining two field lists that drift apart?
11+
12+
## Recommended solution
13+
14+
**Do not author two forms. Author one field set with enough intent that both forms derive from it — then override only when you must.**
15+
16+
### 1. Put the intent on the fields
17+
18+
The create-form subset is not an arbitrary pick; it is *derivable* from signals that already live on each field:
19+
20+
| Field signal | Effect on the create form |
21+
|---|---|
22+
| `required: true` | **must** appear on create |
23+
| `readonly` / formula / rollup / autonumber / system-stamped | **never** on create (you can't set it) |
24+
| `defaultValue` present | **can be omitted** from create (it self-fills) |
25+
| `hidden` | off everywhere by default |
26+
| `group` | which section the field belongs to (semantic, travels with the model) |
27+
| *declaration order* | the order you write fields in **is** the default order everywhere — there is no `field.order` |
28+
29+
So a sensible create form is: *editable, required-or-core fields, in declaration order* — and it **falls out** of the object. This is ADR-0047's guardrail: **omission is correct** — emit nothing extra and you still get a complete, correct form.
30+
31+
### 2. The default (edit) form derives from `field.group`
32+
33+
The full edit form materialises each `field.group` into a section. You can omit it entirely and let the platform derive an equivalent grouped form. When you do write it, list fields as **bare strings** so each one inherits its type / validation / FLS / default from the object — the form carries layout only, never data semantics.
34+
35+
### 3. Hand-shape the create form *only when layout or flow diverges*
36+
37+
The escape hatch is a **named form view** bound to the create entry point. Author it as a **sparse override** — mostly a list of field names — not a from-scratch restatement:
38+
39+
```ts
40+
import { defineView } from '@objectstack/spec';
41+
42+
const data = { provider: 'object' as const, object: 'showcase_contact' };
43+
44+
export const ContactViews = defineView({
45+
list: {
46+
type: 'grid', data,
47+
columns: [{ field: 'name' }, { field: 'email' }, { field: 'company' }, { field: 'stage' }],
48+
// Bind the "+ Add record" entry point to the slim create form:
49+
addRecord: { enabled: true, mode: 'form', formView: 'create' },
50+
},
51+
52+
// Full edit form — grouped by field.group; bare strings inherit field defs.
53+
form: {
54+
type: 'simple', data,
55+
sections: [
56+
{ name: 'contact', label: 'Contact', columns: 2, fields: ['name', 'email', 'phone'] },
57+
{ name: 'work', label: 'Work', columns: 2, fields: ['company', 'title'] },
58+
{ name: 'status', label: 'Status', columns: 2, fields: ['stage', 'lead_score'] },
59+
{ name: 'notes', label: 'Notes', columns: 1, fields: ['notes'] },
60+
],
61+
},
62+
63+
formViews: {
64+
// Sparse create override: only the core fields, one section. Omits
65+
// lead_score (readonly), stage (defaulted), notes (edit-time only).
66+
create: {
67+
type: 'simple', data, title: 'New contact',
68+
sections: [
69+
{ label: 'Who is this?', columns: 1, fields: ['name', 'email', 'phone', 'company'] },
70+
],
71+
},
72+
},
73+
});
74+
```
75+
76+
The binding is `addRecord.mode: 'form'` + `addRecord.formView: 'create'` ([`AddRecordConfigSchema`](/docs/references/ui/view)). No `formViews.create` → the create entry derives a default. Present → it wins for create.
77+
78+
## Why
79+
80+
Three layers, each *derive + only store differences* — never "re-list all 40 fields":
81+
82+
```
83+
1. Derived default derive(object, 'create' | 'edit') ← free, no authoring
84+
2. Author override formViews.create (sparse patch) ← this recipe; only on real divergence
85+
3. Tenant override org overlay delta (ADR-0005) ← a single org wants its own form
86+
```
87+
88+
Welding two independent full forms is the **Salesforce page-layout tax**: add a required field, forget the create form → runtime "missing required field" on create; rename a field → silent drift. Keeping data semantics on the object (never on the form) means a form can only ever drift on *which fields appear* — a flat name list that **reference-integrity diagnostics catch as a hard failure** in the AI loop (ADR-0047 §3.5, ADR-0033). That guardrail is what makes the escape hatch safe to hand to an AI author.
89+
90+
The create fields are a **prefix/subset of the edit fields in the same order and groups**, so "quick-create 4 fields → save → land on the full record" stays visually continuous. Derivation preserves that for free; two hand-authored forms break it.
91+
92+
## When to actually reach for the override
93+
94+
| Your real need | Use |
95+
|---|---|
96+
| Create asks fewer fields (required + a few core) | **Pure derivation** — don't hand-write |
97+
| Create groups differently but is just a smaller subset | Usually still derivation (a 5-field form needs no sections) |
98+
| Create is a **wizard / multi-step**, has create-only copy, conditional reveal, bespoke layout | **Hand-write `formViews.create`** |
99+
100+
Rule of thumb: **"different field subset" → derive. "different layout or flow" → override.** Writing a full create form just to drop a few fields walks straight back into the two-artifact drift trap.
101+
102+
## Runnable example
103+
104+
- Object: [`examples/app-showcase/src/objects/contact.object.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/objects/contact.object.ts) — flat, grouped, intent-tagged field set.
105+
- Views: [`examples/app-showcase/src/views/contact.view.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/views/contact.view.ts) — full edit form + sparse `formViews.create` + `addRecord` binding.
106+
107+
## Anti-patterns
108+
109+
- **Two full hand-authored field lists** (`contact_create_form` + `contact_edit_form`). Guaranteed drift; the create form silently misses new required fields.
110+
- **Restating field type / validation / options on the form.** Data semantics live on the object only; the form chooses *which fields and where*.
111+
- **Reaching for `formViews.create` to drop fields.** That's derivation's job — reserve the override for genuine layout/flow divergence.
112+
113+
## See also
114+
115+
- Decision record: **ADR-0047** (object UI run modes — derive-default + override).
116+
- [Field grouping & order](/docs/guides/solutions/field-grouping-and-order) — the three different meanings of "group".
117+
- Reference: [View / FormView schema](/docs/references/ui/view), [Field schema](/docs/references/data/field).
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
---
2+
title: Who can see data / automation / interface
3+
description: Map a concrete access requirement onto the platform's layers — object CRUD, field-level security, row-level security, capabilities, app/nav gating, and the run-identity of automations.
4+
---
5+
6+
# Who can see data / automation / interface
7+
8+
## Scenario
9+
10+
> I have a real access requirement — "sales reps see only their own deals; managers see the team's; finance can export; nobody but admins touches the automations." How do these map onto the platform's authorization layers?
11+
12+
## Recommended solution
13+
14+
Authorization splits into three decoupled concerns (ADR-0066): **capability** (what can be done), **assignment** (who holds it — permission sets / roles, maintained at runtime), and **requirement** (what a resource declares it needs). A resource declares *what is required*; it never bakes in *who*.
15+
16+
Pick the layer that matches the requirement:
17+
18+
| Requirement | Layer | Where |
19+
|---|---|---|
20+
| Can read/create/edit/delete an object type | Object CRUD | permission-set `objects.{allowCreate/Read/Edit/Delete}` |
21+
| Can see / edit a specific field | Field-level security (FLS) | permission-set `fields`, or field `requiredPermissions` |
22+
| Which *rows* a user sees | Row-level security (RLS) | permission-set `rowLevelSecurity` (CEL `using` / depth `readScope`) |
23+
| A functional capability (export, approve, manage) | Capability | `systemPermissions` ↔ resource `requiredPermissions` |
24+
| Can open an app / nav entry / page | Surface access | `App.requiredPermissions`, `tabPermissions`, nav gating |
25+
| Can run / configure an automation | Capability + run-identity | `manage_metadata` to edit; flow `runAs` to execute |
26+
27+
### Data: CRUD + FLS + RLS, combined
28+
29+
```ts
30+
definePermissionSet({
31+
name: 'sales_rep',
32+
objects: { deal: { allowRead: true, allowCreate: true, allowEdit: true } },
33+
fields: { 'deal.commission': { readable: false } }, // FLS: hide a field
34+
rowLevelSecurity: [
35+
{ name: 'own_deals', object: 'deal', operation: 'all', using: 'owner_id == current_user.id' },
36+
],
37+
});
38+
```
39+
40+
- **"My own"** vs **"my team's"** vs **"the org's"** is the RLS *depth* axis (`readScope` / `writeScope`: `own | own_and_reports | unit | unit_and_below | org`, ADR-0057). A manager set uses `unit`; a rep uses `own`.
41+
- Grants combine **most-permissively** across a user's sets; the tenant-isolation policy `AND`s on top; the superuser bypass (`viewAllRecords` / `modifyAllRecords`) short-circuits RLS where the object's posture allows it (ADR-0066).
42+
- For genuinely sensitive objects, set `access: { default: 'private' }` so they are **not** covered by blanket wildcard grants — access needs an explicit grant.
43+
44+
### Automation: the run-identity is the safety decision
45+
46+
Two separate questions:
47+
48+
1. **Who can configure it?** Editing flows/automations needs `manage_metadata` (typically Studio users). Don't expose automation config to end users — see [role-based interfaces](/docs/guides/solutions/role-based-interfaces).
49+
2. **As whom does it run?** A flow's `runAs` (ADR-0049):
50+
- `runAs: 'user'` (default) — runs as the triggering user; CRUD nodes respect that user's RLS. **Safer default.**
51+
- `runAs: 'system'` — elevated, bypasses RLS. Make elevation *explicit*, and surface it in the UI as "runs as system".
52+
53+
A schedule-triggered run has no user, so under `user` it runs unscoped — declare `system` to make that intentional.
54+
55+
### Interface: gate the app, the nav, and the action
56+
57+
`App.requiredPermissions` gates the whole app; permission-set `tabPermissions` controls per-app/tab visibility; each nav item carries `requiredPermissions` / `visible` / `requiresObject`. Action gates are **dual-surface** (ADR-0066 D4): hidden/disabled in the UI **and** rejected by the server — UI gating is derived from the same declaration, server is the source of truth.
58+
59+
## Why
60+
61+
Keeping capability, assignment and requirement decoupled means resources stay stable (`requiredPermissions: ['export_data']`) while admins re-assign who holds `export_data` at runtime, with no code change. The fixed precedence (AND-gates → most-permissive grant union → RLS → explicit deny) is specified in ADR-0066, so combinations are predictable rather than ad-hoc.
62+
63+
## Runnable example
64+
65+
- Built-in profiles: [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts) (`member_default` shows owner-scoped RLS + tenant isolation).
66+
- Security objects: [`packages/plugins/plugin-security/src/objects`](https://github.com/objectstack-ai/framework/tree/main/packages/plugins/plugin-security/src/objects).
67+
68+
## Anti-patterns
69+
70+
- **UI-only action gating.** Always pair it with the server check — use a single `requiredPermissions` declaration (dual-surface), never hide a button while leaving the endpoint open.
71+
- **`runAs: 'system'` by reflex.** Default to `user`; elevate only where a step genuinely needs to bypass RLS, and say so.
72+
- **Baking "who" into a resource.** Declare the capability required; assign holders in permission sets at runtime.
73+
74+
## See also
75+
76+
- Decision records: **ADR-0066** (unified authorization), **ADR-0057** (access depth), **ADR-0049** (`runAs`).
77+
- Cheatsheet: [Permissions matrix](/docs/guides/cheatsheets/permissions-matrix); guide: [Security](/docs/guides/security).
78+
- Reference: [Permission](/docs/references/security/permission), [RLS](/docs/references/security/rls), [Sharing](/docs/references/security/sharing).

0 commit comments

Comments
 (0)