Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion content/docs/guides/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Guides
description: Task-oriented tutorials for building applications with ObjectStack
---

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

# Developer Guides

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

## Solutions (Scenario Cookbook)

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*.

<Cards>
<Card
icon={<Lightbulb />}
title="Scenario Cookbook"
href="/docs/guides/solutions"
description="Create-vs-edit forms, field grouping, role-based interfaces, access control, public forms, approvals — each with a runnable example."
/>
</Cards>

## Foundations

<Cards>
Expand Down
2 changes: 2 additions & 0 deletions content/docs/guides/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"root": true,
"pages": [
"index",
"---Solutions---",
"solutions",
"---Foundations---",
"packages",
"metadata",
Expand Down
67 changes: 67 additions & 0 deletions content/docs/guides/solutions/approval-workflow.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
title: Approval workflow
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.
---

# Approval workflow

## Scenario

> 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?

## Recommended solution

An approval is a **flow** with an **approval node**. Two access decisions matter as much as the routing itself.

### 1. Who can configure it

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)).

### 2. As whom does it run — the safety decision

A flow declares `runAs` (ADR-0049), and for approvals this is the decision that keeps it safe:

- `runAs: 'user'` (default) — the flow's data operations run as the **submitter**, respecting their RLS. Good when the flow only touches records the submitter can already see.
- `runAs: 'system'` — **elevated**, bypasses RLS. Needed when the flow must read/write records the submitter can't (e.g. post to a ledger, notify an approver who owns rows the submitter can't see). Declare it **explicitly** so the elevation is visible, not accidental.

A schedule-triggered escalation has no triggering user — so it must be `system` to act at all.

### 3. The approval node

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.

```ts
// Illustrative — see the Approval reference for the exact node schema.
defineFlow({
name: 'invoice_approval',
runAs: 'user', // submitter's RLS unless a step needs more
trigger: { object: 'invoice', on: 'create' },
nodes: [
{ type: 'approval', approver: { type: 'role', role: 'finance_manager' },
onApprove: 'mark_approved', onReject: 'mark_rejected' },
],
});
```

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).

## Why

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.

## Runnable example

- Flows: [`examples/app-showcase/src/flows`](https://github.com/objectstack-ai/framework/tree/main/examples/app-showcase/src/flows).
- Approval schema: [`packages/spec/src/automation/approval.zod.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/spec/src/automation/approval.zod.ts).

## Anti-patterns

- **`runAs: 'system'` everywhere** "to make it work". Default to `user`; elevate a single step only when it genuinely needs to bypass RLS.
- **Exposing automation config to approvers/submitters.** Configuring the flow is a `manage_metadata` (builder) concern; acting on a request is not.
- **Gating "Approve" only in the UI.** Make `approve_*` a capability and gate the action so the server enforces it too.

## See also

- Decision records: **ADR-0049** (`runAs`), **ADR-0066** (unified authorization).
- Guide: [Business Logic](/docs/guides/business-logic) (Flows, Approval Nodes); [Who can see data / automation / interface](/docs/guides/solutions/data-automation-interface-access).
- Reference: [Flow](/docs/references/automation/flow), [Approval](/docs/references/automation/approval).
117 changes: 117 additions & 0 deletions content/docs/guides/solutions/create-vs-edit-form.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
title: Create form ≠ edit form
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.
---

# Create form ≠ edit form

## Scenario

> 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?

## Recommended solution

**Do not author two forms. Author one field set with enough intent that both forms derive from it — then override only when you must.**

### 1. Put the intent on the fields

The create-form subset is not an arbitrary pick; it is *derivable* from signals that already live on each field:

| Field signal | Effect on the create form |
|---|---|
| `required: true` | **must** appear on create |
| `readonly` / formula / rollup / autonumber / system-stamped | **never** on create (you can't set it) |
| `defaultValue` present | **can be omitted** from create (it self-fills) |
| `hidden` | off everywhere by default |
| `group` | which section the field belongs to (semantic, travels with the model) |
| *declaration order* | the order you write fields in **is** the default order everywhere — there is no `field.order` |

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.

### 2. The default (edit) form derives from `field.group`

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.

### 3. Hand-shape the create form *only when layout or flow diverges*

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:

```ts
import { defineView } from '@objectstack/spec';

const data = { provider: 'object' as const, object: 'showcase_contact' };

export const ContactViews = defineView({
list: {
type: 'grid', data,
columns: [{ field: 'name' }, { field: 'email' }, { field: 'company' }, { field: 'stage' }],
// Bind the "+ Add record" entry point to the slim create form:
addRecord: { enabled: true, mode: 'form', formView: 'create' },
},

// Full edit form — grouped by field.group; bare strings inherit field defs.
form: {
type: 'simple', data,
sections: [
{ name: 'contact', label: 'Contact', columns: 2, fields: ['name', 'email', 'phone'] },
{ name: 'work', label: 'Work', columns: 2, fields: ['company', 'title'] },
{ name: 'status', label: 'Status', columns: 2, fields: ['stage', 'lead_score'] },
{ name: 'notes', label: 'Notes', columns: 1, fields: ['notes'] },
],
},

formViews: {
// Sparse create override: only the core fields, one section. Omits
// lead_score (readonly), stage (defaulted), notes (edit-time only).
create: {
type: 'simple', data, title: 'New contact',
sections: [
{ label: 'Who is this?', columns: 1, fields: ['name', 'email', 'phone', 'company'] },
],
},
},
});
```

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.

## Why

Three layers, each *derive + only store differences* — never "re-list all 40 fields":

```
1. Derived default derive(object, 'create' | 'edit') ← free, no authoring
2. Author override formViews.create (sparse patch) ← this recipe; only on real divergence
3. Tenant override org overlay delta (ADR-0005) ← a single org wants its own form
```

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.

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.

## When to actually reach for the override

| Your real need | Use |
|---|---|
| Create asks fewer fields (required + a few core) | **Pure derivation** — don't hand-write |
| Create groups differently but is just a smaller subset | Usually still derivation (a 5-field form needs no sections) |
| Create is a **wizard / multi-step**, has create-only copy, conditional reveal, bespoke layout | **Hand-write `formViews.create`** |

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.

## Runnable example

- Object: [`examples/app-showcase/src/objects/contact.object.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/objects/contact.object.ts) — flat, grouped, intent-tagged field set.
- Views: [`examples/app-showcase/src/views/contact.view.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/views/contact.view.ts) — full edit form + sparse `formViews.create` + `addRecord` binding.

## Anti-patterns

- **Two full hand-authored field lists** (`contact_create_form` + `contact_edit_form`). Guaranteed drift; the create form silently misses new required fields.
- **Restating field type / validation / options on the form.** Data semantics live on the object only; the form chooses *which fields and where*.
- **Reaching for `formViews.create` to drop fields.** That's derivation's job — reserve the override for genuine layout/flow divergence.

## See also

- Decision record: **ADR-0047** (object UI run modes — derive-default + override).
- [Field grouping & order](/docs/guides/solutions/field-grouping-and-order) — the three different meanings of "group".
- Reference: [View / FormView schema](/docs/references/ui/view), [Field schema](/docs/references/data/field).
78 changes: 78 additions & 0 deletions content/docs/guides/solutions/data-automation-interface-access.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
title: Who can see data / automation / interface
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.
---

# Who can see data / automation / interface

## Scenario

> 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?

## Recommended solution

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*.

Pick the layer that matches the requirement:

| Requirement | Layer | Where |
|---|---|---|
| Can read/create/edit/delete an object type | Object CRUD | permission-set `objects.{allowCreate/Read/Edit/Delete}` |
| Can see / edit a specific field | Field-level security (FLS) | permission-set `fields`, or field `requiredPermissions` |
| Which *rows* a user sees | Row-level security (RLS) | permission-set `rowLevelSecurity` (CEL `using` / depth `readScope`) |
| A functional capability (export, approve, manage) | Capability | `systemPermissions` ↔ resource `requiredPermissions` |
| Can open an app / nav entry / page | Surface access | `App.requiredPermissions`, `tabPermissions`, nav gating |
| Can run / configure an automation | Capability + run-identity | `manage_metadata` to edit; flow `runAs` to execute |

### Data: CRUD + FLS + RLS, combined

```ts
definePermissionSet({
name: 'sales_rep',
objects: { deal: { allowRead: true, allowCreate: true, allowEdit: true } },
fields: { 'deal.commission': { readable: false } }, // FLS: hide a field
rowLevelSecurity: [
{ name: 'own_deals', object: 'deal', operation: 'all', using: 'owner_id == current_user.id' },
],
});
```

- **"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`.
- 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).
- For genuinely sensitive objects, set `access: { default: 'private' }` so they are **not** covered by blanket wildcard grants — access needs an explicit grant.

### Automation: the run-identity is the safety decision

Two separate questions:

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).
2. **As whom does it run?** A flow's `runAs` (ADR-0049):
- `runAs: 'user'` (default) — runs as the triggering user; CRUD nodes respect that user's RLS. **Safer default.**
- `runAs: 'system'` — elevated, bypasses RLS. Make elevation *explicit*, and surface it in the UI as "runs as system".

A schedule-triggered run has no user, so under `user` it runs unscoped — declare `system` to make that intentional.

### Interface: gate the app, the nav, and the action

`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.

## Why

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.

## Runnable example

- 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).
- Security objects: [`packages/plugins/plugin-security/src/objects`](https://github.com/objectstack-ai/framework/tree/main/packages/plugins/plugin-security/src/objects).

## Anti-patterns

- **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.
- **`runAs: 'system'` by reflex.** Default to `user`; elevate only where a step genuinely needs to bypass RLS, and say so.
- **Baking "who" into a resource.** Declare the capability required; assign holders in permission sets at runtime.

## See also

- Decision records: **ADR-0066** (unified authorization), **ADR-0057** (access depth), **ADR-0049** (`runAs`).
- Cheatsheet: [Permissions matrix](/docs/guides/cheatsheets/permissions-matrix); guide: [Security](/docs/guides/security).
- Reference: [Permission](/docs/references/security/permission), [RLS](/docs/references/security/rls), [Sharing](/docs/references/security/sharing).
Loading