From 119576b95bb0db3ff106aaf61661aa75f0368359 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 22:38:22 +0000 Subject: [PATCH] docs(guides): add scenario cookbook (Solutions) + create-vs-edit-form example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NvGeDWAo3rrjc3CQ8vmAac --- content/docs/guides/index.mdx | 15 ++- content/docs/guides/meta.json | 2 + .../guides/solutions/approval-workflow.mdx | 67 ++++++++++ .../guides/solutions/create-vs-edit-form.mdx | 117 ++++++++++++++++++ .../data-automation-interface-access.mdx | 78 ++++++++++++ .../solutions/field-grouping-and-order.mdx | 84 +++++++++++++ content/docs/guides/solutions/index.mdx | 55 ++++++++ content/docs/guides/solutions/meta.json | 13 ++ .../solutions/public-data-collection.mdx | 71 +++++++++++ .../solutions/role-based-interfaces.mdx | 75 +++++++++++ .../src/objects/contact.object.ts | 65 ++++++++++ examples/app-showcase/src/objects/index.ts | 1 + .../app-showcase/src/views/contact.view.ts | 80 ++++++++++++ examples/app-showcase/src/views/index.ts | 1 + 14 files changed, 723 insertions(+), 1 deletion(-) create mode 100644 content/docs/guides/solutions/approval-workflow.mdx create mode 100644 content/docs/guides/solutions/create-vs-edit-form.mdx create mode 100644 content/docs/guides/solutions/data-automation-interface-access.mdx create mode 100644 content/docs/guides/solutions/field-grouping-and-order.mdx create mode 100644 content/docs/guides/solutions/index.mdx create mode 100644 content/docs/guides/solutions/meta.json create mode 100644 content/docs/guides/solutions/public-data-collection.mdx create mode 100644 content/docs/guides/solutions/role-based-interfaces.mdx create mode 100644 examples/app-showcase/src/objects/contact.object.ts create mode 100644 examples/app-showcase/src/views/contact.view.ts diff --git a/content/docs/guides/index.mdx b/content/docs/guides/index.mdx index 8c0aa07507..a87bdd15c8 100644 --- a/content/docs/guides/index.mdx +++ b/content/docs/guides/index.mdx @@ -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 @@ -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. +## 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*. + + + } + 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." + /> + + ## Foundations diff --git a/content/docs/guides/meta.json b/content/docs/guides/meta.json index a1a3676e9e..586011a75e 100644 --- a/content/docs/guides/meta.json +++ b/content/docs/guides/meta.json @@ -4,6 +4,8 @@ "root": true, "pages": [ "index", + "---Solutions---", + "solutions", "---Foundations---", "packages", "metadata", diff --git a/content/docs/guides/solutions/approval-workflow.mdx b/content/docs/guides/solutions/approval-workflow.mdx new file mode 100644 index 0000000000..e835089233 --- /dev/null +++ b/content/docs/guides/solutions/approval-workflow.mdx @@ -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). diff --git a/content/docs/guides/solutions/create-vs-edit-form.mdx b/content/docs/guides/solutions/create-vs-edit-form.mdx new file mode 100644 index 0000000000..3f28f6628c --- /dev/null +++ b/content/docs/guides/solutions/create-vs-edit-form.mdx @@ -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). diff --git a/content/docs/guides/solutions/data-automation-interface-access.mdx b/content/docs/guides/solutions/data-automation-interface-access.mdx new file mode 100644 index 0000000000..5793c24049 --- /dev/null +++ b/content/docs/guides/solutions/data-automation-interface-access.mdx @@ -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). diff --git a/content/docs/guides/solutions/field-grouping-and-order.mdx b/content/docs/guides/solutions/field-grouping-and-order.mdx new file mode 100644 index 0000000000..028b25f643 --- /dev/null +++ b/content/docs/guides/solutions/field-grouping-and-order.mdx @@ -0,0 +1,84 @@ +--- +title: Field grouping & order +description: The data model is a flat field set, but forms need sections. Where grouping actually lives — semantic field.group vs form sections vs a table's row grouping — and why those three "groups" are different things. +--- + +# Field grouping & order + +## Scenario + +> From the object-definition angle, fields are **flat**. In a table they're flat columns too. But a form needs them **grouped** into sections. Where does the grouping live — on the field, or on the form? And do I have to re-group on every form? + +## Recommended solution + +**Grouping is a property of a *presentation*, not of the field set. The model stays flat; a form imposes grouping; a table doesn't.** The same fields, three lenses: + +| Lens | Field shape | Why | +|---|---|---| +| **Object definition** | flat field set | the model describes *what data exists*, not how to arrange it | +| **Table / grid** | flat columns | a grid is a record × field matrix; the field axis is naturally flat | +| **Form / record page** | grouped into sections | a form shows one record to a human, who needs visual chunking | + +So "flat vs grouped" is not a contradiction — it's one flat set seen through different lenses. + +### You declare grouping once; surfaces inherit it + +There are **two** grouping concepts. Keep them distinct: + +**1. Semantic grouping — `field.group` (on the object).** A field's logical home ("billing", "contact_info", "system"). It travels with the model. The Studio field editor folds fields by it, and auto-generated forms use it as the **default** sectioning — so you are not starting from zero. + +```ts +fields: { + name: Field.text({ label: 'Full name', group: 'contact' }), + email: Field.email({ label: 'Email', group: 'contact' }), + stage: Field.select({ label: 'Stage', group: 'status', options: [/* … */] }), +} +``` + +**2. Layout grouping — form `sections` (on a view).** A specific form's explicit arrangement: which fields, which section, how many columns, collapsible. It can **inherit** `field.group` as the default or **override** it per form. + +```ts +form: { + type: 'simple', + sections: [ + { name: 'contact', label: 'Contact', columns: 2, fields: ['name', 'email', 'phone'] }, + { name: 'status', label: 'Status', columns: 2, fields: ['stage'] }, + ], +} +``` + +### Order is the order you author in + +There is **no `field.order`**. The order you declare fields in the object **is** the default display order everywhere — and form `sections` (and the fields within them) are themselves an ordered list, so group order and intra-group order are captured at authoring time. Both the create and edit projections inherit that order, which keeps "quick-create subset" and "full form" visually continuous (see [Create form ≠ edit form](/docs/guides/solutions/create-vs-edit-form)). + +## The trap: a table's "group" is a *different thing* + +A grid view also has `grouping` / `groupByField` — but that groups **records (rows)** by a field's **value** (all `stage = qualified` rows together), not **fields (columns)** into sections. The word "group" means two unrelated axes: + +| Surface | "group" means | Axis | +|---|---|---| +| Form | fields → sections | columns (visual chunking) | +| Grid | records → buckets by value | rows (data pivot) | + +Conflating these two is the single most common source of exactly this confusion. They share a word and nothing else. + +## Why + +Keeping grouping off the field (beyond an optional semantic hint) is what lets **one model project to many surfaces**: a create form, a full edit form, a mobile form, a public intake form, role-specific layouts, a flat table, and an API — each arranging the *same* fields differently. Weld a single layout onto the field and you get exactly one arrangement everywhere, which every surface must then share. The semantic `field.group` is the deliberate middle ground: a default that travels, without dictating layout. + +## Runnable example + +- [`examples/app-showcase/src/objects/contact.object.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/objects/contact.object.ts) — fields tagged with `group`. +- [`examples/app-showcase/src/views/contact.view.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/views/contact.view.ts) — sections that materialise those groups. + +## Anti-patterns + +- **Adding structural nesting to the data model to satisfy a form.** The model is flat; let the form group. +- **Re-typing the grouping in every form.** Declare `field.group` once; forms inherit and only override on real divergence. +- **Assuming a grid "group" will section your form fields.** It buckets rows by value — a different axis entirely. + +## See also + +- [Create form ≠ edit form](/docs/guides/solutions/create-vs-edit-form). +- Reference: [Field schema](/docs/references/data/field) (`group`), [View schema](/docs/references/ui/view) (`sections`, `grouping`). +- Studio: [Object Designer](/docs/references/studio/object-designer) — field editor groups by `field.group`. diff --git a/content/docs/guides/solutions/index.mdx b/content/docs/guides/solutions/index.mdx new file mode 100644 index 0000000000..7142bba6d6 --- /dev/null +++ b/content/docs/guides/solutions/index.mdx @@ -0,0 +1,55 @@ +--- +title: Solutions (Scenario Cookbook) +description: Goal-oriented recipes — given a concrete business scenario, the recommended way to solve it across objects, fields, forms, views, permissions and automation. Each recipe links to a runnable example and the decision record behind it. +--- + +# Solutions — Scenario Cookbook + +The rest of the guides answer **"how does feature X work"** (Field, View, Flow, Permission…). These recipes answer the other question: **"I have business problem Y — what's the recommended way to solve it?"** — and the answer usually cuts across several metadata types at once. + + +**Why this section exists.** The mechanisms are all documented in the [references](/docs/references) and per-type [metadata guides](/docs/guides/metadata). What was missing is the *connective tissue*: which mechanisms to combine for a given goal, why, and what the anti-patterns are. These recipes are also the **few-shot corpus for AI authoring** — an AI that has seen the recipe generates the right shape (ADR-0047 §3.5: "zero examples is why zero AI generations use these features"). + + +## How each recipe is structured + +1. **Scenario** — the business problem in one sentence. +2. **Recommended solution** — the cross-cutting approach (objects + fields + forms + views + permissions as needed). +3. **Why** — the reasoning, linked to the relevant ADR. +4. **Runnable example** — a link into `examples/app-showcase`. +5. **Anti-patterns** — what *not* to do, and why it bites later. + +## Recipes + + + + + + + + + diff --git a/content/docs/guides/solutions/meta.json b/content/docs/guides/solutions/meta.json new file mode 100644 index 0000000000..b8114831f5 --- /dev/null +++ b/content/docs/guides/solutions/meta.json @@ -0,0 +1,13 @@ +{ + "title": "Solutions", + "icon": "Lightbulb", + "pages": [ + "index", + "create-vs-edit-form", + "field-grouping-and-order", + "role-based-interfaces", + "data-automation-interface-access", + "public-data-collection", + "approval-workflow" + ] +} diff --git a/content/docs/guides/solutions/public-data-collection.mdx b/content/docs/guides/solutions/public-data-collection.mdx new file mode 100644 index 0000000000..43b9bf60df --- /dev/null +++ b/content/docs/guides/solutions/public-data-collection.mdx @@ -0,0 +1,71 @@ +--- +title: Collect data from the public +description: Expose one form to anonymous visitors (web-to-lead, contact-us, intake) without opening the underlying base. Authorization is derived from the form's own declaration; only whitelisted fields are accepted. +--- + +# Collect data from the public + +## Scenario + +> I want anonymous visitors to submit a form — a "Contact us" / "Request a demo" / intake form — that creates a record. But I must **not** give them access to the base, and they must only be able to set the fields on the form. + +## Recommended solution + +**Declare a public form view; the submit route derives a narrow authorization from that declaration.** No `guest` profile, no broad grant — and it works even under secure-by-default auth (ADR-0056 Option A). + +### 1. Mark a form view public + +```ts +formViews: { + contact: { + type: 'simple', + data: { provider: 'object', object: 'showcase_inquiry' }, + sections: [ + { label: 'Tell us about yourself', columns: 1, fields: [ + { field: 'name', required: true }, + { field: 'email', required: true }, + { field: 'company' }, + { field: 'message', required: true }, + ]}, + ], + sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' }, + submitBehavior: { kind: 'thank-you', title: 'Thanks!', message: 'We will be in touch.' }, + }, +} +``` + +That `sharing` block wires the anonymous endpoints automatically: + +``` +GET /api/v1/forms/contact-us → resolved form + whitelisted schema +POST /api/v1/forms/contact-us/submit → INSERT one showcase_inquiry +``` + +### 2. The field whitelist *is* the security boundary + +Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side. Server-controlled fields (`status`, `source`, owner stamps) are set by a defaults hook, never by the submitter. The submit route derives a narrow `publicFormGrant: { object: 'showcase_inquiry' }` — create + read-back of the row it just made, and nothing else. + +### 3. Keep the object private once inside + +Set `sharingModel: 'private'` on the object so submissions are staff-scoped after creation. The public path only ever **inserts**; it never lists. + +## Why + +Authorization is **derived from the declaration**, not configured separately — so the grant can't drift wider than the form. There is no standing "anonymous can write to this object" rule to misconfigure: the only thing the public can do is create one record through one whitelisted form. This is the difference from Airtable, where interfaces can't be shared publicly at all (only forms can) — here the same FormView metadata renders both internally (authed) and publicly (anonymous) through one renderer. + +## Runnable example + +- Object: [`examples/app-showcase/src/objects/inquiry.object.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/objects/inquiry.object.ts). +- Public form: [`examples/app-showcase/src/views/inquiry.view.ts`](https://github.com/objectstack-ai/framework/blob/main/examples/app-showcase/src/views/inquiry.view.ts). + +## Anti-patterns + +- **Granting a `guest` profile broad write** to the object. Let the form declaration derive a narrow grant instead. +- **Relying on the UI to hide server-controlled fields.** The submit whitelist is the boundary; fields not listed are stripped regardless of the client. +- **Leaving the object's `sharingModel` open** so submissions are world-readable. Keep it `private`; the public path only inserts. + +## See also + +- Decision record: **ADR-0056** (public forms — Option A). +- Guide: [Forms (Public + Internal)](/docs/guides/public-forms). +- Reference: [Sharing](/docs/references/ui/sharing), [View / FormView schema](/docs/references/ui/view). diff --git a/content/docs/guides/solutions/role-based-interfaces.mdx b/content/docs/guides/solutions/role-based-interfaces.mdx new file mode 100644 index 0000000000..38d58d1aa7 --- /dev/null +++ b/content/docs/guides/solutions/role-based-interfaces.mdx @@ -0,0 +1,75 @@ +--- +title: Role-based interfaces +description: The same data serves different audiences. Give end users a curated app/page and keep builder surfaces — Studio, raw object tables, automation config — out of their view. Separate consumer and builder paths by default. +--- + +# Role-based interfaces + +## Scenario + +> The same object is touched by two very different audiences: **builders** who design the schema and **end users** who just enter and read data. How do I give each the right surface — without end users seeing (and being confused by) the builder chrome? + +## Recommended solution + +**Separate the consumer surface from the builder surface by default — don't rely on each admin to hide things by hand.** ObjectStack has two first-class run modes (ADR-0047); navigation type decides which a user gets. + +| Audience | Surface | Gated by | +|---|---|---| +| **End user (consumer)** | a curated **App** → `page` / `view` (interface mode) | `App.requiredPermissions`, permission-set `tabPermissions`, nav-item gating | +| **Builder / admin** | **Setup / Studio**, raw object tables (data mode) | capabilities: `setup.access`, `studio.access`, `manage_metadata` | + +The built-in profiles already encode this split: `member_default` and `viewer_readonly` do **not** carry `studio.access` / `manage_metadata`, so Studio and schema-design surfaces are invisible to them; `admin_full_access` and `organization_admin` do (see [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts)). + +### 1. Gate the app and its navigation + +```ts +defineApp({ + name: 'crm', + label: 'CRM', + requiredPermissions: ['crm.access'], // who may open the app at all + navigation: [ + { id: 'nav_contacts', type: 'object', label: 'Contacts', objectName: 'showcase_contact' }, + // Builder-only entries are gated so consumers never render them: + { id: 'nav_designer', type: 'component', label: 'Object Designer', + componentRef: 'metadata:resource', params: { type: 'object' }, + requiredPermissions: ['manage_metadata'] }, + ], +}); +``` + +Each nav item supports three independent gates ([`app.zod`](/docs/references/ui/app)): + +- `requiredPermissions` — RBAC capability (e.g. `manage_metadata`). +- `visible` — a CEL predicate (e.g. `P\`'org_admin' in current_user.roles\``). +- `requiresObject` / `requiresService` — hide unless a runtime object/service is installed. + +**Hide, don't disable.** A disabled-but-visible builder entry is still noise and still confuses end users. Gated nav items are *not rendered* for users who lack the capability. + +### 2. Hand end users a page, not the raw table + +In **data mode** (`navigation → object`) users get the permissive grid: switchable views, personal views, a full toolbar. In **interface mode** (`navigation → page`) the author curates exactly what's exposed — selected columns, fixed visualisation, only the filters and actions you enabled. For end users, prefer interface-mode pages. ADR-0047's default is deliberately asymmetric: data mode is open, interface mode is closed until the author opens it. + +### 3. Curate the data surface itself + +Use a [curated view](/docs/guides/solutions/field-grouping-and-order) (selected fields, progressive disclosure) rather than granting read on the raw object and dropping users onto a 40-column grid. + +## Why + +This is the lesson from Airtable's surfaces: the **Automations tab is visible to every base collaborator, even read-only** — so end users see builder chrome they can't use and don't understand. ObjectStack avoids that by making "consumer vs builder" a capability boundary that the built-in profiles already draw, plus per-nav gating that *omits* (not merely disables) what a user can't use. Action gates are **dual-surface** (ADR-0066 D4): the ActionRunner hides/disables an action in the UI **and** the server rejects the call — no "UI-gated but server-open" footgun. + +## Runnable example + +- 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` / `viewer_readonly` vs `organization_admin` / `admin_full_access`. +- Apps: [`examples/app-showcase/src/apps`](https://github.com/objectstack-ai/framework/tree/main/examples/app-showcase/src/apps). + +## Anti-patterns + +- **Making every end user a builder-grade collaborator** and then hiding tabs one by one. Make the split the default. +- **Disabling builder entries instead of hiding them.** Visible-but-dead chrome is still confusing. +- **Granting raw object read to end users** when a curated page would expose exactly what they need. + +## See also + +- Decision records: **ADR-0047** (run modes), **ADR-0066** (unified authorization, dual-surface gates). +- [Who can see data / automation / interface](/docs/guides/solutions/data-automation-interface-access). +- Guide: [Security](/docs/guides/security); reference: [App schema](/docs/references/ui/app), [Permission schema](/docs/references/security/permission). diff --git a/examples/app-showcase/src/objects/contact.object.ts b/examples/app-showcase/src/objects/contact.object.ts new file mode 100644 index 0000000000..5dcce8213e --- /dev/null +++ b/examples/app-showcase/src/objects/contact.object.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * Contact — the canonical "create form ≠ edit form" example. + * + * See the scenario guide: content/docs/guides/solutions/create-vs-edit-form.mdx + * and ADR-0047 (object UI run modes) for the model this demonstrates. + * + * The object declares a FLAT field set. Each field carries enough *intent* that + * both the full edit form AND the slimmed create form can be DERIVED from this + * single source — no second hand-maintained field list: + * + * - `group` → which section a field belongs to (semantic grouping that + * travels with the data model, not a per-form layout). + * - declaration order = display order (there is no `field.order`; the order + * you write fields in IS the default order everywhere). + * - `required` → must appear on the create form. + * - `readonly` / derived → never on the create form (you can't set it). + * - `defaultValue` present → can be OMITTED from create (it self-fills). + * + * `views/contact.view.ts` then shows the two projections: + * - the full grouped edit form (mirrors what the platform auto-derives), and + * - a SPARSE `formViews.create` override for the rare case where you want to + * hand-shape the create experience. + */ +export const Contact = ObjectSchema.create({ + name: 'showcase_contact', + label: 'Contact', + pluralLabel: 'Contacts', + icon: 'user', + description: + 'Demonstrates derive-default + sparse-override forms: one flat, grouped, intent-tagged field set projects into both a full edit form and a slim create form (ADR-0047).', + sharingModel: 'private', + + fields: { + // ── Contact group: the core identity. These are what a create form asks. ── + name: Field.text({ label: 'Full name', required: true, searchable: true, maxLength: 120, group: 'contact' }), + email: Field.email({ label: 'Email', required: true, searchable: true, group: 'contact' }), + phone: Field.text({ label: 'Phone', maxLength: 40, group: 'contact' }), + + // ── Work group: useful context, not required to bring a contact into being. ── + company: Field.text({ label: 'Company', maxLength: 120, searchable: true, group: 'work' }), + title: Field.text({ label: 'Job title', maxLength: 120, group: 'work' }), + + // ── Status group: lifecycle. `stage` has a default (so it can be omitted + // from create); `lead_score` is derived/readonly (so it must never be + // on the create form — you can't set it). + stage: Field.select({ + label: 'Stage', + group: 'status', + options: [ + { label: 'New', value: 'new', default: true, color: '#3B82F6' }, + { label: 'Working', value: 'working', color: '#F59E0B' }, + { label: 'Qualified', value: 'qualified', color: '#10B981' }, + { label: 'Closed', value: 'closed', color: '#6B7280' }, + ], + }), + lead_score: Field.number({ label: 'Lead score', readonly: true, group: 'status', inlineHelpText: 'Computed by scoring rules — not user-editable.' }), + + // ── Notes group: long-form, edit-time only. ── + notes: Field.text({ label: 'Notes', maxLength: 4000, group: 'notes' }), + }, +}); diff --git a/examples/app-showcase/src/objects/index.ts b/examples/app-showcase/src/objects/index.ts index 9a3a7c2813..9be96ed97a 100644 --- a/examples/app-showcase/src/objects/index.ts +++ b/examples/app-showcase/src/objects/index.ts @@ -12,3 +12,4 @@ export { Preference } from './preference.object.js'; export { PrivateNote } from './private-note.object.js'; export { Announcement } from './announcement.object.js'; export { Inquiry } from './inquiry.object.js'; +export { Contact } from './contact.object.js'; diff --git a/examples/app-showcase/src/views/contact.view.ts b/examples/app-showcase/src/views/contact.view.ts new file mode 100644 index 0000000000..de42bd34bf --- /dev/null +++ b/examples/app-showcase/src/views/contact.view.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'showcase_contact' }; + +/** + * Contact views — the "create form ≠ edit form" pattern. + * + * Scenario guide: content/docs/guides/solutions/create-vs-edit-form.mdx + * Decision record: ADR-0047 (object UI run modes — derive-default + override). + * + * Two projections of ONE flat, grouped field set (see objects/contact.object.ts): + * + * • `form` (default edit/detail) — the FULL record, grouped into sections by + * each field's `group`. This mirrors what the platform auto-derives from + * `field.group`; it is written out explicitly here only so the example is + * legible. In the target model you can OMIT it and get an equivalent + * grouped form for free. Sections list fields as bare strings → every + * field inherits its type / validation / FLS / default from the object. + * + * • `formViews.create` (the escape hatch) — a SPARSE override for the create + * experience: just the core fields, one ungrouped section. Note what is + * absent: `lead_score` (readonly/derived — can't be set), `stage` (has a + * default — self-fills), `notes` (edit-time only). Nothing here restates a + * field's type or rules; it only chooses WHICH fields appear and WHERE. + * + * The list view binds the create entry point to that form via + * `addRecord: { mode: 'form', formView: 'create' }`. + */ +export const ContactViews = defineView({ + // Default list — carries `data` so the view registrar can resolve the object. + list: { + label: 'Contacts', + type: 'grid', + data, + columns: [ + { field: 'name' }, + { field: 'email' }, + { field: 'company' }, + { field: 'stage' }, + ], + // Create uses the slim, hand-shaped form rather than the full edit form. + addRecord: { enabled: true, mode: 'form', formView: 'create' }, + }, + + // ── Default (edit/detail) form: the full record, grouped by `field.group`. + // Bare field names → inherit everything from the object definition. ── + 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 derived (`lead_score`), defaulted (`stage`) and edit-time + // (`notes`) fields. `owner_id`-style overrides would go inline as + // `{ field: 'phone', required: true }` — only where you actually + // override; everything else stays a bare string and inherits. ── + create: { + type: 'simple', + data, + title: 'New contact', + sections: [ + { + label: 'Who is this?', + columns: 1, + fields: ['name', 'email', 'phone', 'company'], + }, + ], + submitBehavior: { kind: 'thank-you', title: 'Contact created', message: 'You can fill in the rest on the record.' }, + }, + }, +}); diff --git a/examples/app-showcase/src/views/index.ts b/examples/app-showcase/src/views/index.ts index 0d26324e15..ef8f99f0f4 100644 --- a/examples/app-showcase/src/views/index.ts +++ b/examples/app-showcase/src/views/index.ts @@ -4,3 +4,4 @@ export { TaskViews } from './task.view.js'; export { ProjectViews } from './project.view.js'; export { InquiryViews } from './inquiry.view.js'; export { BusinessUnitViews } from './business-unit.view.js'; +export { ContactViews } from './contact.view.js';