From 56f8f2f6f8890220ca3cc2b6f0db02c624953cad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:35:58 +0000 Subject: [PATCH 1/3] docs: add builder deep-dive pages for data, interface, and automation New Build-section pages covering the core-feature gaps against the ObjectStack framework docs: relationships, formulas, validation rules; interface landing, apps & navigation, forms, dashboards, custom pages; automation landing, state-machine workflows, and approval processes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0145MY7RdJr8KuRMyn5oKAR6 --- content/docs/build/automation/approvals.mdx | 165 ++++++++++++++ content/docs/build/automation/index.mdx | 39 ++++ content/docs/build/automation/workflows.mdx | 160 +++++++++++++ content/docs/build/data/formulas.mdx | 223 +++++++++++++++++++ content/docs/build/data/relationships.mdx | 194 ++++++++++++++++ content/docs/build/data/validation-rules.mdx | 220 ++++++++++++++++++ content/docs/build/interface/apps.mdx | 170 ++++++++++++++ content/docs/build/interface/dashboards.mdx | 223 +++++++++++++++++++ content/docs/build/interface/forms.mdx | 210 +++++++++++++++++ content/docs/build/interface/index.mdx | 57 +++++ content/docs/build/interface/pages.mdx | 177 +++++++++++++++ 11 files changed, 1838 insertions(+) create mode 100644 content/docs/build/automation/approvals.mdx create mode 100644 content/docs/build/automation/index.mdx create mode 100644 content/docs/build/automation/workflows.mdx create mode 100644 content/docs/build/data/formulas.mdx create mode 100644 content/docs/build/data/relationships.mdx create mode 100644 content/docs/build/data/validation-rules.mdx create mode 100644 content/docs/build/interface/apps.mdx create mode 100644 content/docs/build/interface/dashboards.mdx create mode 100644 content/docs/build/interface/forms.mdx create mode 100644 content/docs/build/interface/index.mdx create mode 100644 content/docs/build/interface/pages.mdx diff --git a/content/docs/build/automation/approvals.mdx b/content/docs/build/automation/approvals.mdx new file mode 100644 index 0000000..d5064fc --- /dev/null +++ b/content/docs/build/automation/approvals.mdx @@ -0,0 +1,165 @@ +--- +title: Approvals +description: Route records for human sign-off — without letting the automation quietly bypass row-level security. +--- + +# Approvals + +**An approval is a [flow](/docs/build/automation/flows) with an approval node: the flow pauses until a human approves or rejects, then resumes down the matching branch.** There is no separate approval engine to learn — triggers, branches, and error handling all work exactly as they do in any other flow. What this page adds is the approval node itself, and the two access decisions that matter as much as the routing. + +## Who does what + +Three roles, deliberately separated: + +| Role | Can | Needs | +|---|---|---| +| **Builder** | Author and edit the approval flow | `manage_metadata` (typically Studio users) | +| **Submitter** | Submit records that enter the flow | Normal record access — no automation config | +| **Approver** | Act on approval requests (approve / reject) | A capability granted by their permission set | + +End users submit records and act on requests; they never edit the automation. +Keep automation configuration surfaces out of consumer apps. + +## As whom does it run — the safety decision + +A flow declares `runAs`, and for approvals this is the decision that keeps the +flow from quietly bypassing row-level security: + +| `runAs` | Data operations run as | Use when | +|---|---|---| +| `'user'` (default) | The **submitter**, respecting their RLS | The flow only touches records the submitter can already see | +| `'system'` | **Elevated** — bypasses RLS | The flow must read/write records the submitter can't (post to a ledger, notify an approver who owns rows the submitter can't see) | + +Declare elevation **explicitly** so it's visible, not accidental. The 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. + +> **Tip:** A schedule-triggered escalation has no triggering user — it must be +> `runAs: 'system'` to act at all. That's the legitimate case for elevation; +> "`system` everywhere to make it work" is the anti-pattern. + +## The approval node + +The node declares **who approves** and how their decisions aggregate: + +```ts +{ + id: 'manager_approval', + type: 'approval', + label: 'Manager Approval', + config: { + approvers: [{ type: 'field', value: 'owner_manager_id' }], + behavior: 'unanimous', + approvalStatusField: 'approval_status', + lockRecord: true, + }, +} +``` + +| Config | What it does | +|---|---| +| `approvers` | Who must decide — a named user, a position, or a user resolved from a record field (e.g. the submitter's manager) | +| `behavior` | How multiple approvers aggregate, e.g. `'unanimous'` | +| `approvalStatusField` | Optional record field the plugin mirrors the request status into | +| `lockRecord` | Lock the record against edits while the request is pending | + +> **Warning — `position` vs `role`.** `{ type: 'position', value: 'finance_manager' }` +> routes to the holders of a position (`sys_user_position`). The `role` approver +> type is the org-membership tier (`sys_member.role`: `owner`/`admin`/`member`) — +> a position name authored as `type: 'role'` matches nobody and the request +> stalls. `os lint` flags this (`approval-role-not-membership-tier`). + +## A complete approval flow + +Route large proposals to the owner's manager, then branch on the decision: + +```ts +export const opportunityApproval = defineFlow({ + name: 'opportunity_approval', + label: 'Opportunity Approval', + type: 'record_change', + status: 'active', + runAs: 'user', // submitter's RLS unless a step genuinely needs more + nodes: [ + { + id: 'start', + type: 'start', + config: { + triggerType: 'record-after-update', + objectName: 'opportunity', + condition: "record.amount >= 50000 && record.stage == 'proposal'", + }, + }, + { + id: 'manager_approval', + type: 'approval', + label: 'Manager Approval', + config: { + approvers: [{ type: 'field', value: 'owner_manager_id' }], + behavior: 'unanimous', + approvalStatusField: 'approval_status', + lockRecord: true, + }, + }, + { id: 'mark_approved', type: 'update_record', label: 'Mark Approved' }, + { id: 'mark_rejected', type: 'update_record', label: 'Mark Rejected' }, + { id: 'end', type: 'end' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'manager_approval' }, + { id: 'approved', source: 'manager_approval', target: 'mark_approved', label: 'approve' }, + { id: 'rejected', source: 'manager_approval', target: 'mark_rejected', label: 'reject' }, + { id: 'e4', source: 'mark_approved', target: 'end' }, + { id: 'e5', source: 'mark_rejected', target: 'end' }, + ], +}); +``` + +Note the labeled edges: `approve` and `reject` name the post-decision +branches. Always model the rejection path — a flow with only an approve branch +strands rejected records. + +**Multi-step approvals:** chain multiple `approval` nodes. **Parallel +approvals:** see the aggregating-node pattern in +[Flows](/docs/build/automation/flows). + +## What the approver experiences + +The `@objectstack/plugin-approvals` package owns the durable approval state. +While a request is pending: + +1. The plugin persists the request (`sys_approval_request`) and every decision + taken on it (`sys_approval_action`) — your approval history is queryable data. +2. With `lockRecord: true`, the record is locked against edits until the + decision lands. +3. If you set `approvalStatusField`, the record's own field mirrors the request + status, so views and reports can filter on it. +4. The approver approves or rejects; the plugin resumes the paused flow down + the matching edge. + +Approving is itself a gated action. Model "may approve" as a capability (e.g. +`approve_invoice`) granted by the approver's permission set, and gate the +approve action's `requiredPermissions` on it — the gate is then enforced on +**both** the UI and the server, not just hidden from a screen. + +## Best practices + +| Do | Don't | +|---|---| +| Define clear entry criteria | Create too many approval steps | +| Set reasonable timeout periods | Make approvals too complex | +| Allow recall when appropriate | Forget rejection paths | +| Notify all stakeholders | Hard-code approvers | +| Track approval history | Gate "Approve" only in the UI | +| Default to `runAs: 'user'`, elevate one step at a time | Set `runAs: 'system'` everywhere "to make it work" | + +## Where to go next + +| Page | Why | +|---|---| +| [Flows](/docs/build/automation/flows) | The flow reference this page builds on — triggers, steps, error handling | +| [Workflows](/docs/build/automation/workflows) | Constrain the lifecycle the approval sits inside | +| [Actions](/docs/build/interface/actions) | Surface submit and approve as buttons in the interface | +| [CEL expressions](/docs/reference/cel) | The language behind entry conditions | +| [Email](/docs/configure/email) | Notify approvers and stakeholders | +| [Automation overview](/docs/build/automation) | Chooser: flow vs workflow vs approval | diff --git a/content/docs/build/automation/index.mdx b/content/docs/build/automation/index.mdx new file mode 100644 index 0000000..74634a7 --- /dev/null +++ b/content/docs/build/automation/index.mdx @@ -0,0 +1,39 @@ +--- +title: Automation +description: Pick the right tool for the job — flows for steps, workflows for state, approvals for human sign-off. +--- + +# Automation + +**Automation attaches business logic to your data model declaratively — as metadata the runtime executes — instead of scattering it through application code.** Three tools cover the ground, and picking the right one first saves you from rebuilding later. + +## Which one do I need? + +| You need | Use | Read | +|---|---|---| +| "When X happens, do Y" — steps that run on a record change, a schedule, or a button click | **Flow** | [Flows](/docs/build/automation/flows) | +| "This record may only move through these states, by these events" — a controlled lifecycle | **Workflow** (state machine) | [Workflows](/docs/build/automation/workflows) | +| "A person must sign off before this proceeds" — routed human decisions | **Approval** | [Approvals](/docs/build/automation/approvals) | + +Rule of thumb: model **state** with workflows, model **steps** with flows. Approvals are not a separate engine — an approval is a flow that pauses at an approval node until a human decides. + +## How they combine + +The three compose rather than compete: + +- A **workflow** constrains which lifecycle transitions are valid at all — states, transitions, and guards, nothing else. +- A **flow** performs the side effects around those transitions: send an email, update records, call an external service, wait, branch. +- An **approval node** inside a flow blocks execution until an approver acts, then resumes down the approve or reject branch. + +So "a case moves new → assigned → resolved" is a workflow; "notify the manager when it escalates" is a flow; "a discount over $50k needs the finance manager's sign-off" is a flow with an approval node. + +> **Tip:** If a request reads "when X happens, do Y", it's a flow. If it reads "this record must never skip a state", it's a workflow. Start there and you'll rarely choose wrong. + +## Where to go next + +| Page | Why | +|---|---| +| [Flows](/docs/build/automation/flows) | The full flow reference — triggers, step types, error handling, CEL | +| [Workflows](/docs/build/automation/workflows) | States, transitions, and guards for strict lifecycles | +| [Approvals](/docs/build/automation/approvals) | Approval nodes, run-as identity, and the approver experience | +| [CEL expressions](/docs/reference/cel) | The expression language used in conditions and guards | diff --git a/content/docs/build/automation/workflows.mdx b/content/docs/build/automation/workflows.mdx new file mode 100644 index 0000000..96495a1 --- /dev/null +++ b/content/docs/build/automation/workflows.mdx @@ -0,0 +1,160 @@ +--- +title: Workflows +description: Model a record's lifecycle as a state machine — valid states, guarded transitions, and nothing a flow can do better. +--- + +# Workflows + +**A workflow models a record's lifecycle as a finite state machine: the states the record can be in, the events that move it between them, and the guards that must hold for a move to happen.** Use one when the core requirement is "this object can only move through these states by these events." + +There is no standalone Salesforce-style Workflow Rule authoring type. The old +"workflow" concept splits cleanly in two: + +- **State machine metadata** for strict lifecycle transitions — this page. +- **[Flows](/docs/build/automation/flows)** for event-triggered or scheduled + automation, including [approval](/docs/build/automation/approvals) pauses. + +## Workflows vs flows + +| | Workflow (state machine) | Flow | +|---|---|---| +| Models | *State* — where a record is in its lifecycle | *Steps* — what happens when something occurs | +| Answers | "Is this transition allowed right now?" | "What do we do about it?" | +| Shape | States, transitions, guards | Nodes and edges: triggers, actions, branches | +| Side effects | None — it only constrains | All of them — email, updates, HTTP, waits | + +They compose: the state machine constrains the transitions; flows perform the +side effects around them when you need notifications, record updates, or +external calls. + +## Define a state machine + +A support case that must move `new → assigned → resolved`, with an escalation +path: + +```ts +import type { StateMachineConfig } from '@objectstack/spec/automation'; + +export const caseLifecycle: StateMachineConfig = { + id: 'case_lifecycle', + initial: 'new', + states: { + new: { + on: { + ASSIGN: { target: 'assigned' }, + }, + }, + assigned: { + on: { + RESOLVE: { target: 'resolved', cond: 'has_resolution' }, + ESCALATE: { target: 'escalated' }, + }, + }, + escalated: { + on: { + RESOLVE: { target: 'resolved', cond: 'has_resolution' }, + }, + }, + resolved: { + type: 'final', + }, + }, +}; +``` + +Read it as a contract: a `new` case can only be assigned. An `assigned` case +can be resolved — but only if the `has_resolution` guard passes — or escalated. +A `resolved` case is final; nothing moves it again. + +## Anatomy + +| Key | What it declares | +|---|---| +| `id` | The state machine's identifier | +| `initial` | The state every new record starts in | +| `states` | Map of state name → its outgoing transitions | +| `on` | Events this state responds to (`ASSIGN`, `RESOLVE`, …) | +| `target` | The state an event moves the record to | +| `cond` | A guard that must hold for the transition to fire | +| `type: 'final'` | Terminal state — no outgoing transitions | + +### Guards + +A guard (`cond`) makes a transition conditional: in the example above, +`RESOLVE` only reaches `resolved` when `has_resolution` holds. Guards are how +you encode "you can't close a case without a resolution" as a structural rule +instead of a validation scattered through UI code. + +### Events, not field writes + +Transitions fire on named **events** (`ASSIGN`, `ESCALATE`), not on arbitrary +status-field edits. That's the point: the machine defines the complete set of +legal moves, and anything not declared is impossible. + +> **Tip:** Keep the machine minimal — states, transitions, guards. The moment +> you want "and then send an email", you've left workflow territory: put the +> side effect in a [flow](/docs/build/automation/flows) that reacts to the +> transition. + +## Side effects belong in flows + +State machines describe valid transitions and guards — nothing else. When a +transition should *do* something (notify sales, stamp a closed date, call an +external system), pair the machine with a flow triggered by the record change: + +```ts +export const dealClosedWon = defineFlow({ + name: 'deal_closed_won', + type: 'record_change', + nodes: [ + { + id: 'start', + type: 'start', + config: { + triggerType: 'record-after-update', + objectName: 'opportunity', + condition: "record.stage == 'closed_won' && previous.stage != 'closed_won'", + }, + }, + { id: 'set_closed_date', type: 'update_record', label: 'Set Closed Date' }, + { id: 'notify_sales', type: 'notify', label: 'Notify Sales' }, + { id: 'end', type: 'end' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'set_closed_date' }, + { id: 'e2', source: 'set_closed_date', target: 'notify_sales' }, + { id: 'e3', source: 'notify_sales', target: 'end' }, + ], +}); +``` + +The machine guarantees the deal *reached* `closed_won` legally; the flow +handles what happens next. Conditions like the one above are CEL expressions — +see [CEL](/docs/reference/cel). + +## Migrating from workflow rules + +If you're coming from a platform with Workflow Rules, here's the mapping: + +| Old concept | Current equivalent | +|---|---| +| Workflow Rule | Flow | +| Time trigger | Scheduled flow | +| Field update action | `update_record` node | +| Email alert | `notify` node | +| HTTP call | `http` node | +| Approval Process | Flow with one or more `approval` nodes | + +The test: if the old rule was "when X happens, do Y", model it as a small +flow. If it was "this record must move through controlled states", model the +lifecycle as a state machine and use flows for the side effects. + +## Where to go next + +| Page | Why | +|---|---| +| [Flows](/docs/build/automation/flows) | Side effects around transitions — triggers, steps, error handling | +| [Approvals](/docs/build/automation/approvals) | Human sign-off as a pause inside a flow | +| [CEL expressions](/docs/reference/cel) | The language behind guards and flow conditions | +| [Data modeling](/docs/build/data) | The objects whose lifecycles you're constraining | +| [Automation overview](/docs/build/automation) | Chooser: flow vs workflow vs approval | diff --git a/content/docs/build/data/formulas.mdx b/content/docs/build/data/formulas.mdx new file mode 100644 index 0000000..ea71845 --- /dev/null +++ b/content/docs/build/data/formulas.mdx @@ -0,0 +1,223 @@ +--- +title: Formulas +description: Computed fields, dynamic defaults, and conditional logic — one CEL expression language everywhere metadata needs to think. +--- + +# Formulas + +**One expression language, every surface.** Wherever a piece of +metadata needs to compute a value or evaluate a condition, ObjectOS +uses [CEL](/docs/reference/cel) (Google's Common Expression Language) — +formula fields, dynamic defaults, conditional visibility, validation +conditions, flow decisions. Learn the syntax once, use it everywhere. + +| Surface | What CEL does there | +|---|---| +| **Formula fields** (`type: 'formula'`) | Compute a read-time value from other fields | +| **Dynamic defaults** (``defaultValue: cel`...` ``) | Evaluate at insert time, not compile time | +| **Field predicates** (`visibleWhen`, `readonlyWhen`, `requiredWhen`) | Show / lock / require a field conditionally | +| **[Validation rules](/docs/build/data/validation-rules)** (`condition`) | Flag invalid records | +| **[Views](/docs/build/interface/views)** (`visibleOn`, `conditionalFormatting`) | Conditional sections and row styling | +| **[Flows](/docs/build/automation/flows)** (decisions, hook conditions) | Branch on record state | + +This page covers how builders *use* formulas. The full language — +operators, the standard library, the `Expression` envelope — lives in +the [CEL reference](/docs/reference/cel). + +## Formula fields + +A formula field computes its value at read time from a CEL expression. +It is read-only — never directly writable: + +```ts +import { F } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const Invoice = ObjectSchema.create({ + name: 'invoice', + fields: { + subtotal: Field.decimal({ label: 'Subtotal' }), + tax_rate: Field.decimal({ label: 'Tax Rate' }), + + total: Field.formula({ + label: 'Total', + returnType: 'number', + expression: F`record.subtotal + record.subtotal * record.tax_rate`, + }), + }, +}); +``` + +Two keys matter: + +| Key | What it does | +|---|---| +| `expression` | The CEL source — the **only** key the runtime evaluates | +| `returnType` | What the formula returns: `'number'`, `'text'`, `'boolean'`, `'date'` — read by dashboards and formatting | + +> **Warning:** A formula field's `type` is always `formula`. Do **not** +> pass `type: 'currency'` or `type: 'number'` to `Field.formula` — it +> would override `type: 'formula'` and the field silently never +> computes. Declare the value type with `returnType` instead. + +### Everyday patterns + +```ts +// Tiered label +Field.formula({ + label: 'Discount Tier', + returnType: 'text', + expression: ` + record.amount > 1000000 ? "Platinum" : + record.amount > 500000 ? "Gold" : + record.amount > 100000 ? "Silver" : "Bronze" + `, +}) + +// Days until close +Field.formula({ + label: 'Days to Close', + returnType: 'number', + expression: 'daysBetween(record.close_date, today())', +}) + +// Full name — joinNonEmpty skips blank parts +Field.formula({ + label: 'Full Name', + returnType: 'text', + expression: F`joinNonEmpty([record.salutation, record.first_name, record.last_name], ' ')`, +}) + +// Gross margin with 2 decimal places +Field.formula({ + label: 'Gross Margin %', + returnType: 'number', + expression: 'record.revenue > 0 ? ((record.revenue - record.cost) / record.revenue) * 100 : 0', + scale: 2, +}) +``` + +> **Tip:** CEL throws on `null + string`, so wrap nullable operands in +> `coalesce(record.x, '')` when concatenating — or use `joinNonEmpty` +> and skip the ceremony. + +### Formula as record title + +Since ADR-0079, a record's title is a designated field via `nameField` +— use a text formula for composite titles: + +```ts +ObjectSchema.create({ + name: 'invoice', + nameField: 'display_title', + fields: { + display_title: Field.formula({ + returnType: 'text', + expression: F`"Invoice " + string(record.invoice_no) + " – " + record.customer.name`, + }), + }, +}); +``` + +## Dynamic defaults + +A `defaultValue` wrapped in `` cel`...` `` is evaluated at **insert +time** — on the user's clock and identity, not your laptop's: + +```ts +import { cel } from '@objectstack/spec'; + +issued_date: Field.date({ + defaultValue: cel`today()`, +}), +due_date: Field.date({ + defaultValue: cel`daysFromNow(30)`, +}), +``` + +The same trick applies to seed data: a seed record with +``close_date: cel`daysFromNow(45)` `` resolves when the package is +installed, so demo data is always fresh instead of shipping the +timestamp from compile time. + +## Conditional field behavior + +Predicates make a field react to the rest of the record: + +```ts +import { P } from '@objectstack/spec'; + +po_number: Field.text({ + label: 'PO Number', + requiredWhen: P`record.amount > 10000`, +}), +rating: Field.select({ + label: 'Rating', + options: [ /* ... */ ], + visibleWhen: P`record.status == 'qualified'`, +}), +notes: Field.textarea({ + readonlyWhen: P`record.status == 'approved'`, +}), +``` + +The `F` (formula), `P` (predicate), and `cel` tagged-template helpers +are interchangeable — pick whichever reads best at the call site. + +## CEL in 30 seconds + +| Concept | Syntax | +|---|---| +| Field on the current record | `record.amount` | +| Value before the update | `previous.status` | +| Equality / logic | `==` `!=` `&&` `\|\|` `!` | +| Conditional | `cond ? then : else` | +| Membership | `record.region in ['us', 'eu']` | +| Blank check | `isBlank(record.phone)` | +| Dates | `today()`, `now()`, `daysBetween(a, b)`, `addDays(d, n)` | +| Strings | `upper`, `lower`, `trim`, `contains`, `matches` | + +Full operator table and standard library: +[CEL reference](/docs/reference/cel). + +> **Warning:** Predicates are bare CEL — never wrap field references in +> braces. `{record.rating} >= 4` is a **map literal** and a parse +> error; write `record.rating >= 4`. Braces belong only in `{{ … }}` +> text templates. A malformed expression fails the build and throws at +> runtime — it never silently evaluates to `false`. + +> **Tip:** `has(record.x)` is true whenever the key exists — even when +> the value is `null`. To check "not blank", use `isBlank(...)` or +> `record.x != null`. + +## Best practices + +| Do | Don't | +|---|---| +| Keep formulas simple and readable | Create circular references | +| Test edge cases (null, zero, empty) | Nest ternaries five levels deep | +| Handle nulls with `coalesce` / `isBlank` | Use formulas for frequently changing data | +| Declare `returnType` on every formula | Re-implement logic that a validation rule should own | + +## Build by chat + +You rarely write formulas by hand. Tell the +[AI Builder](/docs/build/ai-builder): + +> *"Add a Days to Close formula on opportunity: days between close_date +> and today."* + +It emits the CEL, infers and stamps `returnType`, and the expression is +validated before it ships — an invalid formula fails the build with a +located error. + +## Where to go next + +| Page | Why | +|---|---| +| [CEL reference](/docs/reference/cel) | The full language: operators, stdlib, expression envelope | +| [Data Model](/docs/build/data) | Where formula fields live | +| [Validation Rules](/docs/build/data/validation-rules) | CEL conditions that block bad writes | +| [Views](/docs/build/interface/views) | CEL in `visibleOn` and `conditionalFormatting` | +| [Flows](/docs/build/automation/flows) | CEL in decisions and hook conditions | +| [Field Types](/docs/reference/field-types) | `formula`, `summary`, `autonumber` and friends | diff --git a/content/docs/build/data/relationships.mdx b/content/docs/build/data/relationships.mdx new file mode 100644 index 0000000..9012d26 --- /dev/null +++ b/content/docs/build/data/relationships.mdx @@ -0,0 +1,194 @@ +--- +title: Relationships +description: Connect objects with lookups and master-detail — cascade rules, filtered pickers, hierarchies, junction objects, and roll-ups. +--- + +# Relationships + +**A relationship is just a field.** Point a `lookup` or `masterDetail` +field at another object and ObjectOS wires up everything on top: +foreign-key integrity, the record picker in Console, related lists on +the parent page, and `expand` in queries. + +| Type | Cardinality | Delete default | Use for | +|---|---|---|---| +| `lookup` | Many-to-one | `set_null` | Loose references (ticket → assignee) | +| `masterDetail` | Many-to-one, owned | `cascade` | Parent-child ownership (order → line items) | +| `tree` | Self-referencing | — | Hierarchies (categories, org charts) | +| `user` | Many-to-one | `set_null` | Person picker — a lookup specialized to `sys_user` | +| `summary` | Roll-up onto parent | — | Aggregate children (sum, count, avg) | + +## Lookup (many-to-one) + +The workhorse. References a record in another object: + +```ts +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const Contact = ObjectSchema.create({ + name: 'contact', + fields: { + account: Field.lookup('account', { + label: 'Account', + required: true, + }), + }, +}); +``` + +The platform validates that the referenced record exists — foreign-key +integrity is enforced on every write. + +> **Tip:** Name the field after the object it points to — `account`, +> not `account_id`. Related lists, `expand`, and the AI Builder all +> read better that way. + +### What happens when the parent is deleted + +Control cascade behavior with `deleteBehavior`: + +| Value | On parent delete | +|---|---| +| `set_null` | Child's reference is cleared (lookup default) | +| `cascade` | Child records are deleted too | +| `restrict` | Delete is blocked while children exist | + +## Filtered lookups + +Constrain which records the picker offers. Use `lookupFilters` for +static conditions and `dependsOn` to scope candidates by another field +on the same record: + +```ts +contact: Field.lookup('contact', { + label: 'Contact', + dependsOn: ['account'], // only contacts of the chosen account + lookupFilters: [ + { field: 'is_active', operator: 'eq', value: true }, + ], +}) +``` + +> **Warning:** The legacy `referenceFilters: string[]` property (e.g. +> `['is_active = true']`) is accepted by the schema but **not read by +> the record-picker UI** — it filters nothing. Always use the +> structured `lookupFilters` + `dependsOn` shown above. + +## Master-detail (owned children) + +`masterDetail` is a lookup with ownership semantics: the child belongs +to the parent, cascade-deletes with it by default, and can be edited +inline as line items on the parent form. + +```ts +export const OrderLine = ObjectSchema.create({ + name: 'order_line', + fields: { + order: Field.masterDetail({ reference: 'order', label: 'Order' }), + product: Field.lookup('product', { label: 'Product' }), + quantity: Field.number({ label: 'Quantity', min: 1 }), + }, +}); +``` + +| Aspect | Behavior | +|---|---| +| Delete | Cascades to children at runtime, unless `deleteBehavior: 'restrict'` | +| Ownership | Child records are owned by the parent record | +| Editing | Optional inline line-item editing on the parent form | +| Roll-ups | `summary` fields are only valid on the master side | + +Choose `lookup` when the reference is optional or shared; choose +`masterDetail` when a child record is meaningless without its parent. + +## Self-referencing lookups & trees + +A lookup can point back at its own object to build hierarchies: + +```ts +parent_account: Field.lookup('account', { + label: 'Parent Account', + description: 'Parent company in hierarchy', +}) +``` + +For dedicated hierarchies (categories, org charts) use the `tree` field +type — a self-referencing lookup stored and expanded like a `lookup`. +Note the engine does **not** run a cycle check on write, so a chain +that loops back on itself is not automatically rejected. + +> **Tip:** Pair a `tree` / self-lookup field with a +> [tree list view](/docs/build/interface/views) to render the hierarchy +> as nested rows in Console. + +## Related lists + +You get these for free. When a lookup points at a parent, child records +automatically appear as a related list on the parent's detail page: + +- Contact has `account: Field.lookup('account')` +- → the Account detail page shows a **Contacts** related list + +No extra configuration needed. + +## Many-to-many: junction objects + +ObjectOS has no direct many-to-many field — model it as a **junction +object**: an object with two relationship fields, one to each side. + +```ts +// Student ↔ Course, joined by Enrollment +export const Enrollment = ObjectSchema.create({ + name: 'enrollment', + fields: { + student: Field.masterDetail({ reference: 'student', label: 'Student' }), + course: Field.masterDetail({ reference: 'course', label: 'Course' }), + grade: Field.select({ label: 'Grade', options: [ /* ... */ ] }), + }, + indexes: [ + { fields: ['student', 'course'], unique: true }, // one enrollment per pair + ], +}); +``` + +Each side sees the junction records as a related list, and the junction +object itself is a natural home for attributes of the pairing (grade, +role, date joined). + +## Roll-ups (`summary` fields) + +Aggregate child records onto the parent with a `summary` field. Only +valid on master objects — the parent side of a master-detail: + +```ts +// On the Order object +total_lines: { + type: 'summary', + label: 'Line Count', + summaryOperations: { + object: 'order_line', // child object to aggregate + field: 'quantity', // field to aggregate + function: 'sum', // count | sum | min | max | avg + }, +} +``` + +Summary fields are read-only and computed from the children — you never +write them directly. + +## Querying across relationships + +Load related records in one call with `expand` — the query API follows +lookup fields and inlines the referenced records, so you don't fan out +N+1 requests from the client. + +## Where to go next + +| Page | Why | +|---|---| +| [Data Model](/docs/build/data) | Objects, fields, and the schema these relationships live in | +| [Formulas](/docs/build/data/formulas) | Compute values across a record with CEL | +| [Validation Rules](/docs/build/data/validation-rules) | Cross-field rules and unique constraints | +| [Views](/docs/build/interface/views) | Tree views, kanban, and related-list surfaces | +| [Field Types](/docs/reference/field-types) | Full reference for `lookup`, `master_detail`, `tree`, `summary` | +| [AI Builder](/docs/build/ai-builder) | Describe the relationship in chat and let the platform wire it | diff --git a/content/docs/build/data/validation-rules.mdx b/content/docs/build/data/validation-rules.mdx new file mode 100644 index 0000000..2814508 --- /dev/null +++ b/content/docs/build/data/validation-rules.mdx @@ -0,0 +1,220 @@ +--- +title: Validation Rules +description: Required fields, unique constraints, and CEL-powered rules that stop bad data at the platform level — with error messages you control. +--- + +# Validation Rules + +**Validation runs on every write path.** REST, Console forms, ObjectQL +— the same rules fire everywhere, so there is no back door for bad +data. A validation rule is a deterministic, synchronous, side-effect-free +predicate over a single record: decidable from the incoming write (and, +on update, the prior record) with no I/O. + +Validation is layered — use the lowest layer that can express the rule: + +| Layer | Expresses | Example | +|---|---|---| +| **Field modifiers** | Presence, uniqueness | `required: true`, `unique: true` | +| **Field constraints** | Per-field shape | `min`, `max`, `maxLength`, `format` | +| **Conditional modifiers** | Context-dependent presence | ``requiredWhen: P`record.amount > 10000` `` | +| **Object validation rules** | Cross-field business logic | "Discount cannot exceed total" | +| **Unique indexes** | Composite / scoped uniqueness | `{ fields: ['code', 'organization'], unique: true }` | +| **Lifecycle hooks** | Arbitrary validation code | `beforeInsert` / `beforeUpdate` | + +## Field-level: required, unique, constraints + +The universal modifiers work on every field type: + +```ts +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +fields: { + email: Field.email({ label: 'Contact Email', required: true, unique: true }), + quantity: Field.number({ label: 'Quantity', min: 1, max: 9999 }), + code: Field.text({ label: 'Code', minLength: 3, maxLength: 20 }), +} +``` + +| Modifier | Behavior | +|---|---| +| `required: true` | Rejects `null` / `undefined` at runtime | +| `unique: true` | Database-level uniqueness constraint — not a racy application check | +| `min` / `max` | Numeric range (number, currency, percent, rating, slider…) | +| `minLength` / `maxLength` | Character bounds on text types | +| `format` | Built-in shape checks — `email`, `url`, `phone` field types carry theirs by default | +| `readonly: true` | Server-enforced on update — a non-system write to the field is silently dropped (insert exempt) | + +Field types also validate themselves for free: `email` checks the +`local@domain` shape, `url` requires a protocol, `select` values must +match an option, `lookup` enforces that the referenced record exists, +`json` must parse. See the +[field-type reference](/docs/reference/field-types) for every default. + +### Conditional required / read-only / visible + +Presence can depend on the rest of the record via CEL predicates: + +```ts +import { P } from '@objectstack/spec'; + +po_number: Field.text({ + label: 'PO Number', + requiredWhen: P`record.amount > 10000`, +}), +``` + +`visibleWhen`, `readonlyWhen`, and `requiredWhen` all take a CEL +predicate — see [Formulas](/docs/build/data/formulas). + +## Object-level validation rules + +Cross-field business logic lives in the object's `validations` array: + +```ts +export const Order = ObjectSchema.create({ + name: 'order', + fields: { + amount: Field.currency({ label: 'Amount', required: true }), + status: Field.select({ label: 'Status', options: [ /* ... */ ] }), + }, + + validations: [ + { + name: 'amount_positive', + type: 'script', + severity: 'error', + message: 'Amount must be greater than zero', + // CEL predicate — TRUE means the record is INVALID. + condition: 'record.amount <= 0', + events: ['insert', 'update'], + }, + ], +}); +``` + +> **Warning:** For `script` rules the `condition` is the **failure** +> predicate — when it evaluates TRUE, validation fails. Write the test +> for the *bad* state: `record.amount <= 0` rejects non-positive +> amounts. + +### Common properties + +Every rule type shares this base shape: + +| Property | Required | Description | +|---|---|---| +| `name` | yes | Unique rule name (`snake_case`) | +| `message` | yes | User-facing error message | +| `type` | yes | `script`, `state_machine`, `format`, `cross_field`, `json_schema`, `conditional` | +| `severity` | no | `error` (blocks save, default), `warning` (allows save), `info` | +| `events` | no | `insert`, `update`, `delete` — default `['insert', 'update']` | +| `priority` | no | 0–9999, lower runs first (default 100) | +| `active` | no | Toggle without deleting (default `true`) | + +### The six rule types + +| Type | Checks | Key config | +|---|---|---| +| `script` | Any CEL predicate | `condition` (TRUE = invalid) | +| `state_machine` | Allowed status transitions | `field`, `transitions` map | +| `format` | One field against regex or built-in format | `field`, `regex` or `format: 'email' \| 'url' \| 'phone' \| 'json'` | +| `cross_field` | Relationship between fields | `fields`, `condition` | +| `json_schema` | JSON field against a JSON Schema | `field`, `schema` | +| `conditional` | Applies a nested rule only when a predicate holds | `when`, `then`, optional `otherwise` | + +Two rules you'll reach for constantly: + +```ts +// State machine — enforce the status flow +{ + name: 'order_status_transitions', + type: 'state_machine', + severity: 'error', + message: 'Invalid status transition', + field: 'status', + transitions: { + draft: ['submitted', 'cancelled'], + submitted: ['approved', 'rejected', 'cancelled'], + approved: ['completed'], + rejected: ['draft'], + cancelled: [], + completed: [], + }, + events: ['update'], +} + +// Cross-field — dates in order +{ + name: 'date_range_valid', + type: 'cross_field', + severity: 'error', + message: 'End date must be after start date', + fields: ['start_date', 'end_date'], + condition: 'record.end_date <= record.start_date', + events: ['insert', 'update'], +} +``` + +In update-time conditions, `previous` holds the pre-change snapshot — +`record.stage != previous.stage` detects a change. + +## Uniqueness: use an index, not a rule + +There is **no** `uniqueness` validation type — on purpose. A +SELECT-then-INSERT check is inherently racy (TOCTOU); a database unique +constraint is not. Enforce uniqueness at the data layer: + +```ts +// Field-level +email: Field.email({ label: 'Contact Email', unique: true }), + +// Composite / scoped via indexes +indexes: [ + { fields: ['code', 'organization'], unique: true }, + // add `partial` for a scoped/conditional constraint +] +``` + +The same logic excludes async/remote validation (a client-form concern +and an SSRF/latency hazard on the write path) and custom handlers — +arbitrary validation code belongs in a `beforeInsert` / `beforeUpdate` +lifecycle hook. + +## Custom error messages + +The `message` is what the user sees when the rule fires — in the +Console form and in the API error response. Make it actionable: + +| Weak | Strong | +|---|---| +| "Invalid input" | "Close date is required for closed deals" | +| "Validation failed" | "Phone must match format: +1-XXX-XXX-XXXX" | +| "Error" | "Discount cannot exceed total" | + +Use `severity` to calibrate: `error` blocks the save, `warning` shows +the message but lets the save through, `info` is purely informational. + +> **Tip:** A predicate that can't be evaluated (parse error, unbound +> variable) is treated as a broken rule — logged and skipped rather +> than blocking every write. + +## Best practices + +| Do | Don't | +|---|---| +| Validate at the lowest layer possible (field → rule → hook) | Use hooks for what a field modifier expresses | +| Write clear, actionable messages | Duplicate the same check across layers | +| Use `priority` to run cheap format checks first | Build overly complex conditions | +| Test rules with real-world data | Block legitimate edge cases | + +## Where to go next + +| Page | Why | +|---|---| +| [Data Model](/docs/build/data) | The objects and fields these rules protect | +| [Formulas](/docs/build/data/formulas) | The CEL language behind `condition` and `requiredWhen` | +| [Relationships](/docs/build/data/relationships) | Referential integrity across objects | +| [CEL reference](/docs/reference/cel) | Full operator and stdlib reference for conditions | +| [Field Types](/docs/reference/field-types) | Built-in validation defaults per field type | +| [Flows](/docs/build/automation/flows) | React to (valid) record changes with automation | diff --git a/content/docs/build/interface/apps.mdx b/content/docs/build/interface/apps.mdx new file mode 100644 index 0000000..b9015a1 --- /dev/null +++ b/content/docs/build/interface/apps.mdx @@ -0,0 +1,170 @@ +--- +title: Apps +description: Bundle objects, views, pages, and dashboards into a branded, navigable shell — and gate exactly who sees what. +--- + +# Apps + +An **app** is a logical container that bundles objects, views, pages, and +dashboards into one cohesive experience. It defines the navigation tree, +the branding, and — critically — who gets in. + +```ts +import { App } from '@objectstack/spec/ui' + +export const CrmApp = App.create({ + name: 'crm_app', + label: 'CRM', + icon: 'briefcase', + branding: { primaryColor: '#2563EB' }, + navigation: [ + { id: 'group_sales', type: 'group', label: 'Sales', icon: 'briefcase', children: [ + { id: 'nav_leads', type: 'object', objectName: 'crm_lead', label: 'Leads', icon: 'funnel' }, + { id: 'nav_accounts', type: 'object', objectName: 'crm_account', label: 'Accounts', icon: 'building' }, + ]}, + ], + requiredPermissions: ['crm_access'], +}) +``` + +## App properties + +| Property | Type | Required | Description | +|:--|:--|:--|:--| +| `name` | `string` | yes | Machine name (`snake_case`) | +| `label` | `string` | yes | Display name | +| `icon` | `string` | — | App icon (Lucide) | +| `description` / `version` | `string` | — | Metadata for listings | +| `active` | `boolean` | — | Is the app active (default `true`) | +| `isDefault` | `boolean` | — | Is this the default app | +| `navigation` | `NavigationItem[]` | — | The navigation tree | +| `branding` | `AppBranding` | — | `primaryColor`, `logo`, `favicon` | +| `requiredPermissions` | `string[]` | — | Who may open the app at all | +| `homePageId` | `string` | — | Nav item `id` to use as the landing page | +| `mobileNavigation` | `object` | — | Mobile-specific navigation | + +## Navigation items + +The navigation tree supports eight item types. The common five are +`object`, `dashboard`, `page`, `url`, and `group`; the spec also defines +`report`, `action`, and `component` items. + +| `type` | Lands on | Key config | +|:--|:--|:--| +| `object` | An object's list view | `objectName`, plus optional targeting (below) | +| `dashboard` | A dashboard | `dashboardName` | +| `page` | A custom page | `pageName`, optional `params` | +| `url` | An external URL | `url`, `target: '_blank'` | +| `group` | Collapsible section | `children`, `expanded` | + +Every item **must** declare a unique `id` (lowercase `snake_case`) — it's +what `homePageId` and `mobileNavigation.bottomNavItems` reference. Shared +properties: `label`, `icon`, `order`, `badge`, plus the gating trio below. + +### Targeting an object entry + +Three optional fields refine where an `object` entry lands, with +precedence `recordId` → `filters` → `viewName`: + +- `viewName` — anchor the entry to a named list view. +- `recordId` — deep-link to a single record ("My Profile"); supports + `{current_user_id}` / `{current_org_id}` template variables. +- `filters` — a one-off parameterized slice: the entry lands on the bare + data surface with each condition as a removable URL filter chip. Great + for "assigned to me" links without authoring a view. Not a security + feature — row-level permissions still decide what's shown. + +```ts +{ id: 'nav_my_open', type: 'object', label: 'My Open Deals', objectName: 'opportunity', + filters: { owner_id: '{current_user_id}', status: 'open' }, icon: 'user-check' } +``` + +### Mobile navigation + +```ts +mobileNavigation: { + mode: 'bottom_nav', // 'drawer' (default) | 'bottom_nav' | 'hamburger' + bottomNavItems: ['nav_home', 'nav_accounts', 'nav_contacts'], // nav item ids, max 5 +} +``` + +## Gating apps by audience + +The same data serves two very different audiences: **builders** who design +the schema, and **end users** who just enter and read data. Separate those +surfaces by default — don't rely on each admin to hide things by hand. + +| Audience | Surface | Gated by | +|:--|:--|:--| +| End user (consumer) | A curated app → `page` / `view` entries | `App.requiredPermissions`, nav-item gating | +| Builder / admin | Setup / Studio, raw object tables | Capabilities: `setup.access`, `studio.access`, `manage_metadata` | + +The built-in permission sets already encode this split: `member_default` +and `viewer_readonly` do **not** carry `studio.access` / +`manage_metadata`, so builder surfaces are invisible to them; +`admin_full_access` and `organization_admin` do. + +Each nav item supports three independent gates: + +| Gate | Type | Hides the item unless… | +|:--|:--|:--| +| `requiredPermissions` | `string[]` | The user holds the RBAC capability (e.g. `manage_metadata`) | +| `visible` | CEL expression | The predicate evaluates true (e.g. `'org_admin' in current_user.positions`) | +| `requiresObject` / `requiresService` | `string` | The named object / kernel service is installed | + +```ts +navigation: [ + { id: 'nav_contacts', type: 'object', label: 'Contacts', objectName: 'showcase_contact' }, + // Builder-only entry — consumers never render it: + { id: 'nav_designer', type: 'component', label: 'Object Designer', + componentRef: 'metadata:resource', params: { type: 'object' }, + requiredPermissions: ['manage_metadata'] }, +] +``` + +> **Hide, don't disable.** A disabled-but-visible builder entry is still +> noise. Gated nav items are *not rendered* for users who lack the +> capability — no greyed-out chrome to confuse end users. + +Two more habits keep consumer surfaces clean: + +- **Hand end users a page, not the raw table.** A `page` entry lets you + curate exactly what's exposed — selected columns, fixed visualization, + only the filters and actions you enabled. An `object` entry gives the + permissive grid: switchable views, personal views, the full toolbar. +- **Curate the data surface itself.** Prefer a + [curated view](/docs/build/interface/views) over granting raw object + read and dropping users onto a 40-column grid. + +Action gates are dual-surface: the UI hides or disables the button **and** +the server rejects the call — there is no "UI-gated but server-open" +footgun. See [Actions](/docs/build/interface/actions). + +## The Setup app — a built-in example + +The platform's own administration UI, the **Setup app**, is itself +rendered from the same app metadata protocol — navigation, pages, and +gating declared as data, drawn by the same renderer as your apps. It's +gated exactly the way your builder surfaces should be: behind the +`setup.access` capability that consumer permission sets don't carry. If +you want proof that apps-as-metadata scales, you're already using it. + +## Anti-patterns + +- **Making every end user a builder-grade collaborator** and hiding tabs + one by one. Make the consumer/builder split the default. +- **Disabling builder entries instead of hiding them.** Visible-but-dead + chrome still confuses. +- **Granting raw object read** when a curated page would expose exactly + what end users need. + +## Where to go next + +| Page | Why | +|:--|:--| +| [Views](/docs/build/interface/views) | What `object` nav entries land on | +| [Pages](/docs/build/interface/pages) | Curated `page` entries for end users | +| [Dashboards](/docs/build/interface/dashboards) | What `dashboard` entries render | +| [Actions](/docs/build/interface/actions) | Buttons and their dual-surface gates | +| [Permissions](/docs/configure/permissions) | The permission sets behind `requiredPermissions` | +| [Interface overview](/docs/build/interface) | How all the pieces compose | diff --git a/content/docs/build/interface/dashboards.mdx b/content/docs/build/interface/dashboards.mdx new file mode 100644 index 0000000..43044d8 --- /dev/null +++ b/content/docs/build/interface/dashboards.mdx @@ -0,0 +1,223 @@ +--- +title: Dashboards +description: Analytics pages built from chart widgets bound to named datasets — with global filters, auto-refresh, and drill-through to the underlying records. +--- + +# Dashboards + +**A dashboard is a grid of widgets; every widget binds to a dataset.** +The dataset is the semantic layer: it owns the base object, joins, +dimensions, and certified measures. Widgets select those by name, so +"revenue" means the same thing on every dashboard and report that uses +it. + +```ts +const salesDashboard = { + name: 'sales_overview', + label: 'Sales Overview', + description: 'Key sales metrics and pipeline analysis', + refreshInterval: 300, // auto-refresh every 5 minutes + + dateRange: { + field: 'close_date', + defaultRange: 'this_quarter', + allowCustomRange: true, + }, + + widgets: [ + { id: 'total_revenue', title: 'Total Revenue', type: 'metric', + dataset: 'sales', values: ['revenue'], + layout: { x: 0, y: 0, w: 3, h: 2 } }, + { id: 'revenue_by_region', title: 'Revenue by Region', type: 'bar', + dataset: 'sales', dimensions: ['region'], values: ['revenue'], + layout: { x: 3, y: 0, w: 6, h: 4 } }, + { id: 'deals_by_month', title: 'Deals by Month', type: 'pie', + dataset: 'sales', dimensions: ['close_month'], values: ['deal_count'], + layout: { x: 9, y: 0, w: 3, h: 4 } }, + ], +} +``` + +## Dashboard properties + +| Property | Type | Required | Description | +|:--|:--|:--|:--| +| `name` | `string` | yes | Machine name (`snake_case`) | +| `label` | `string` | yes | Display label | +| `description` | `string` | — | Dashboard description | +| `widgets` | `DashboardWidget[]` | yes | Chart and metric widgets | +| `refreshInterval` | `number` | — | Auto-refresh interval (seconds) | +| `dateRange` | `object` | — | Global date range filter | +| `globalFilters` | `GlobalFilter[]` | — | Interactive filter controls | + +## Datasets first + +Define the dataset once; bind every widget to it: + +```ts +import { defineDataset } from '@objectstack/spec/ui' + +export const salesDataset = defineDataset({ + name: 'sales', + label: 'Sales', + object: 'opportunity', + include: ['account'], + dimensions: [ + { name: 'region', field: 'account.region', type: 'string' }, + { name: 'close_month', field: 'close_date', type: 'date', dateGranularity: 'month' }, + ], + measures: [ + { name: 'revenue', field: 'amount', aggregate: 'sum', certified: true }, + { name: 'deal_count', aggregate: 'count' }, + ], +}) +``` + +Aggregation lives on the dataset's measures (`aggregate`), not on the +widget: `count`, `sum`, `avg`, `min`, `max`, `count_distinct`, +`array_agg`, `string_agg`. A widget's `dimensions` and `values` must +reference names declared on its bound dataset. + +At runtime, dataset queries run through the analytics service and +automatically apply the caller's row-level security scope to the base +object and joined objects — a dashboard never shows a user records their +[permissions](/docs/configure/permissions) wouldn't. + +> The legacy inline-query shape (`object` + `categoryField` + `valueField` +> + `aggregate` directly on the widget) was removed. Define a dataset and +> bind widgets to it. + +## Widgets + +| Property | Type | Required | Description | +|:--|:--|:--|:--| +| `id` | `string` | yes | Unique widget id (`snake_case`) | +| `dataset` | `string` | yes | Dataset name to bind | +| `values` | `string[]` | yes | Measure names (at least one) | +| `dimensions` | `string[]` | — | Dimension names — X / group / split | +| `type` | `ChartType` | — | Visualization type (default `metric`) | +| `title` / `description` | `string` | — | Display title and subtitle | +| `filter` | `FilterCondition` | — | Presentation-scope filter | +| `layout` | `object` | — | Grid position (auto-flowed when omitted) | +| `colorVariant` | `enum` | — | KPI/card accent color | +| `compareTo` | `enum \| object` | — | Period-over-period comparison window | +| `filterBindings` | `object` | — | Per-widget global-filter mapping (below) | + +### Chart types + +| Type | Best for | +|:--|:--| +| `metric` *(default)* | Single-number KPI — revenue, count, percentage | +| `bar` / `horizontal-bar` / `column` | Category comparison | +| `line` | Trends over time | +| `pie` / `donut` | Distribution | +| `area` | Volume over time | +| `scatter` | Correlation | +| `radar` | Multi-dimensional comparison | +| `funnel` | Conversion stages | +| `gauge` / `solid-gauge` / `bullet` / `kpi` | Progress toward a goal | +| `treemap` / `sankey` | Hierarchical proportions / flows | +| `table` | Detailed records — **drill-through** | +| `pivot` | Cross-tab summaries — **drill-through** | + +### Layout + +Widgets sit on a 12-column grid: + +```ts +layout: { + x: 0, // column position (0-11) + y: 0, // row position + w: 6, // width in columns (1-12) + h: 4, // height in rows +} +``` + +## Drill-down + +`table` and `pivot` widgets are **drill-through**: clicking an aggregated +row or cell opens a side drawer listing the *underlying records* behind +that group. The dataset preserves raw group keys, so the drawer's filter +matches the exact records — no label-to-id guessing. Click any row in +the drawer to open that record's detail: the full +**group → records → record** chain. + +The drawer also offers an **"Open in list →"** escape hatch that +escalates the peek to the object's full list page (sort, bulk-select, +export, shareable URL), scoped by the same drill filter. + +Drill-through is automatic — no per-widget configuration — whenever the +dataset exposes the base object and the widget groups by at least one +dimension. `metric` and chart widgets render the aggregate only; expose +detail through a `table` or `pivot` widget instead. + +## Global date range and filters + +A global time filter applies to all widgets: + +```ts +dateRange: { field: 'created_at', defaultRange: 'this_month', allowCustomRange: true } +``` + +Preset ranges: `today`, `yesterday`, `this_week`, `last_week`, +`this_month`, `last_month`, `this_quarter`, `last_quarter`, `this_year`, +`last_year`, `last_7_days`, `last_30_days`, `last_90_days`, `custom`. + +Interactive global filters work the same way: + +```ts +globalFilters: [ + { name: 'region', field: 'region', label: 'Region', type: 'select' }, + { field: 'owner', label: 'Sales Rep', type: 'lookup' }, +] +``` + +Each filter's `name` (defaults to `field`) is its stable identity — the +key widgets reference in `filterBindings` and the dashboard-level +variable readable in widget expressions as `page.`. The name +`dateRange` is reserved for the built-in date range. + +### Per-widget filter bindings + +By default a filter applies to its own `field` on every widget. When a +widget stores the concept under a different field — or should ignore a +filter — declare `filterBindings`: + +```ts +widgets: [ + // Default binding: dateRange → created_at, region → region. + { id: 'invoices_by_status', /* … */ }, + // This widget's fields differ — map each filter explicitly. + { id: 'accounts_signed', + filterBindings: { dateRange: 'signed_at', region: 'sales_region' }, /* … */ }, + // Opt out of a filter with `false`. + { id: 'total_invoices', filterBindings: { region: false }, /* … */ }, +] +``` + +> Precedence: an explicit `filterBindings` entry (string override or +> `false` opt-out) → the filter's legacy `targetWidgets` allow-list → +> the filter's own `field`. + +## Surfacing a dashboard + +Add a `dashboard` navigation entry to an [app](/docs/build/interface/apps): + +```ts +{ id: 'nav_analytics', type: 'dashboard', label: 'Analytics', + dashboardName: 'sales_overview', icon: 'bar-chart' } +``` + +Or describe it to the [AI Builder](/docs/build/ai-builder) — *"a sales +overview dashboard with revenue by region and a monthly deal trend"* — +and review the generated dataset + dashboard metadata before approving. + +## Where to go next + +| Page | Why | +|:--|:--| +| [Apps](/docs/build/interface/apps) | Put dashboards in navigation | +| [Views](/docs/build/interface/views) | Inline `chart` list views over the same datasets | +| [Pages](/docs/build/interface/pages) | Free-form layouts when a widget grid isn't enough | +| [Data model](/docs/build/data) | The objects your datasets aggregate | +| [Permissions](/docs/configure/permissions) | The row-level security dashboards inherit | diff --git a/content/docs/build/interface/forms.mdx b/content/docs/build/interface/forms.mdx new file mode 100644 index 0000000..49be006 --- /dev/null +++ b/content/docs/build/interface/forms.mdx @@ -0,0 +1,210 @@ +--- +title: Forms +description: Derive create and edit forms from one flat field set, group fields into sections without drift, and control what happens after submit. +--- + +# Forms + +**A form is a projection of your object's fields — not a second field +list.** You declare fields once on the object; forms choose *which fields +and where*. Data semantics (types, validation, defaults, field-level +security) never live on the form, so forms can't drift into lying about +the data. + +Form view *types* (`simple`, `tabbed`, `wizard`, `modal`, …) and the +public-form REST contract are covered in +[Views](/docs/build/interface/views). This page is about the layout +questions: create vs edit, grouping, ordering. + +## Create form ≠ edit form + +The new-record form should ask a handful of essentials; the edit form +shows the full record in sections. **Don't author two forms.** The +create-form subset is *derivable* from intent that already lives 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** — it self-fills | +| `hidden` | Off everywhere by default | +| `group` | Which section the field belongs to | +| *Declaration order* | **Is** the default display order — there is no `field.order` | + +So the sensible create form — *editable, required-or-core fields, in +declaration order* — falls out of the object with zero authoring. +Omission is correct: emit nothing extra and you still get a complete, +correct form. The full edit form derives too, materializing each +`field.group` into a section. + +### 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: + +```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'] }, + ], + }, + + formViews: { + // Sparse create override: only the core fields, one section. + 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'`. +No `formViews.create` → the create entry derives a default. Present → it +wins for create. + +| 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 | +| Create is a wizard / multi-step, has create-only copy, conditional reveal | **Hand-write `formViews.create`** | + +> Rule of thumb: *different field subset* → derive. *Different layout or +> flow* → override. Writing a full create form just to drop fields walks +> straight into the two-artifact drift trap: add a required field, forget +> the create form, get a runtime "missing required field" on create. + +Because the create fields are a subset of the edit fields *in the same +order and groups*, "quick-create 4 fields → save → land on the full +record" stays visually continuous. Derivation preserves that for free. + +## Field grouping and order + +The model is a flat field set. A grid shows flat columns. A form needs +sections. That's not a contradiction — it's one flat set seen through +different lenses: + +| Lens | Field shape | Why | +|:--|:--|:--| +| Object definition | Flat | The model describes *what data exists* | +| Table / grid | Flat columns | A grid is a record × field matrix | +| Form / record page | Grouped sections | A human reading one record needs chunking | + +There are **two** grouping concepts. Keep them distinct: + +**1. Semantic grouping — `field.group` (on the object).** A field's +logical home. It travels with the model and seeds the *default* +sectioning of auto-generated forms: + +```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 inherits `field.group` as the default and overrides it +per form when needed. + +```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` — field +declaration order on the object is the default display order everywhere, +and `sections` (and the fields inside them) are ordered lists. + +> **The "group" trap.** A grid view's `groupByField` groups **records +> (rows)** by a field's *value* — all `stage = qualified` rows together. +> A form section groups **fields (columns)** visually. Same word, two +> unrelated axes. A grid group will never section your form. + +## What happens after submit + +Add `submitBehavior` to a form view to control the post-submit +experience: + +```ts +submitBehavior: { + kind: 'thank-you', + title: 'Thanks!', + message: 'A specialist will reach out within 24 hours.', +} +``` + +| `kind` | Behavior | +|:--|:--| +| `thank-you` *(default)* | Replace the form with a confirmation panel (`title`, `message`) | +| `redirect` | Navigate to `url` after `delayMs` (default 0) — great for marketing pages | +| `continue` | Reset the form for another response — kiosks, batch entry | +| `next-record` | Advance to the next record in a queue (internal mode) | + +Both public and internal forms also honor `?prefill_=` URL +params — seed forms from email links or campaign pages. Prefill is a UX +shortcut, not a permissions bypass: values still go through validation +and (for public forms) the server-side field whitelist. + +``` +/console/f/contact-us?prefill_company=Acme&prefill_email=ada@example.com +/console/forms/quick_create?prefill_lead_source=event_booth_2026 +``` + +## Record detail is driven by roles, not a form binding + +There is no object-level key that pins a form view to the record-detail +screen. Detail rendering derives from the object's cross-surface +**semantic roles**: `nameField` (display name), `highlightFields` (the +strip of most important fields), `stageField` (the lifecycle progress +stepper), and `fieldGroups` + `Field.group` (sectioning shared by forms, +modals, and detail pages). When a record page needs a bespoke layout +beyond what the roles derive, assign the object a custom +[Page](/docs/build/interface/pages). + +## Anti-patterns + +- **Two full hand-authored field lists** (`contact_create_form` + + `contact_edit_form`). Guaranteed drift. +- **Restating field type / validation / options on the form.** Data + semantics live on the object only. +- **Re-typing the grouping in every form.** Declare `field.group` once; + override only on real divergence. +- **Adding structural nesting to the data model to satisfy a form.** The + model stays flat; the form groups. + +## Where to go next + +| Page | Why | +|:--|:--| +| [Views](/docs/build/interface/views) | Form view types, public form sharing, the full view schema | +| [Data model](/docs/build/data) | Where fields — and their intent — are declared | +| [Actions](/docs/build/interface/actions) | `type: 'form'` actions that launch a form view | +| [Pages](/docs/build/interface/pages) | Custom record pages beyond derived layouts | +| [Permissions](/docs/configure/permissions) | Field-level security forms respect | diff --git a/content/docs/build/interface/index.mdx b/content/docs/build/interface/index.mdx new file mode 100644 index 0000000..e6f5ff7 --- /dev/null +++ b/content/docs/build/interface/index.mdx @@ -0,0 +1,57 @@ +--- +title: Interface +description: Apps, views, forms, dashboards, pages, and actions — every user-facing surface declared as metadata, rendered by Console. +--- + +# Interface + +**Everything a user sees is metadata.** You declare interfaces as data — +same lifecycle as your objects: version them, ship them in a package, +let the AI Builder generate them, and any compliant renderer draws them. + +The pieces compose top-down: + +1. An **app** is the shell — navigation, branding, and an audience gate. +2. Its **navigation** points at objects, dashboards, pages, and URLs. +3. An object entry lands on a **view** — grid, kanban, calendar, and more. +4. Opening or creating a record renders a **form** — sections derived + from your field metadata, overridable per mode. +5. **Dashboards** aggregate the same data into chart widgets bound to + named datasets. +6. **Pages** break out of the object frame entirely — free-form layouts + composed from components and regions. +7. **Actions** put buttons on all of the above — one declaration, callable + from Console, REST, flows, and AI agents. + +## The concepts + +| Concept | What it declares | Page | +|:--|:--|:--| +| App | Navigation shell, branding, audience gating | [Apps](/docs/build/interface/apps) | +| View | How records render: grid, kanban, calendar, gantt, … | [Views](/docs/build/interface/views) | +| Form | Create / edit layouts, sections, field grouping | [Forms](/docs/build/interface/forms) | +| Dashboard | Chart widgets, global filters, drill-down | [Dashboards](/docs/build/interface/dashboards) | +| Page | Free-form component layouts; packaged doc pages | [Pages](/docs/build/interface/pages) | +| Action | Named operations surfaced as buttons everywhere | [Actions](/docs/build/interface/actions) | + +Because the UI is data, it respects the same +[permissions](/docs/configure/permissions) as the records it shows, and +the [AI Builder](/docs/build/ai-builder) can generate or modify any of it +through the same typed surface — describe the interface you want, review +the diff, approve. + +> Start with the data. Views and forms derive sensible defaults from your +> [object definitions](/docs/build/data) — you only write interface +> metadata where the default isn't what you want. + +## Where to go next + +| Page | Why | +|:--|:--| +| [Apps](/docs/build/interface/apps) | Wrap your objects in a navigable shell | +| [Views](/docs/build/interface/views) | Choose how records render | +| [Forms](/docs/build/interface/forms) | Shape create and edit experiences | +| [Dashboards](/docs/build/interface/dashboards) | Add analytics widgets | +| [Pages](/docs/build/interface/pages) | Compose free-form layouts | +| [Actions](/docs/build/interface/actions) | Put operations behind buttons | +| [Data model](/docs/build/data) | The objects every surface reads from | diff --git a/content/docs/build/interface/pages.mdx b/content/docs/build/interface/pages.mdx new file mode 100644 index 0000000..7642740 --- /dev/null +++ b/content/docs/build/interface/pages.mdx @@ -0,0 +1,177 @@ +--- +title: Pages +description: Free-form layouts composed from regions, components, and local state — plus Markdown doc pages that ship inside your package. +--- + +# Pages + +**A page is a free-form container.** Unlike a +[view](/docs/build/interface/views), which is bound to a single object, a +page combines multiple components, embeds views, and manages local state +— your home screens, custom record layouts, and utility panels. + +```ts +const homePage = { + name: 'sales_home', + label: 'Sales Home', + type: 'home', + regions: [ + { name: 'header', width: 'full', components: [ + { type: 'metric_card', id: 'total_revenue', label: 'Total Revenue', + properties: { object: 'opportunity', field: 'amount', aggregate: 'sum', format: 'currency' } }, + ]}, + { name: 'main', width: 'large', components: [ + { type: 'list_view', id: 'recent_deals', label: 'Recent Deals', + properties: { object: 'opportunity', view: 'recent_open', limit: 10 } }, + ]}, + ], +} +``` + +## Page properties + +| Property | Type | Required | Description | +|:--|:--|:--|:--| +| `name` | `string` | yes | Machine name (`snake_case`) | +| `label` | `string` | yes | Display label | +| `type` | `enum` | — | Page type (default `'record'`, see below) | +| `object` | `string` | — | Associated object (for `record` type) | +| `template` | `string` | — | Layout template name (default `'default'`) | +| `regions` | `PageRegion[]` | — | Layout zones with components | +| `variables` | `PageVariable[]` | — | Local state variables | +| `isDefault` | `boolean` | — | Is the default page for its type | +| `assignedProfiles` | `string[]` | — | Profiles that can access this page | + +### Page types + +| Type | Use for | +|:--|:--| +| `record` | Custom detail pages tied to one object's records | +| `home` | App landing / entry points | +| `app` | General application layouts | +| `utility` | Tools, settings, wizards | +| `list` | Data-driven interface surfaces | + +Only these five types are valid — earlier roadmap types that never +shipped a renderer were removed from the schema. + +## Regions + +Regions are the layout zones; each holds components: + +```ts +regions: [ + { name: 'sidebar', width: 'small', components: [/* … */] }, + { name: 'content', width: 'large', components: [/* … */] }, +] +``` + +`width` accepts `'small'`, `'medium'`, `'large'`, or `'full'`. + +## Components + +Components are the building blocks inside regions: + +```ts +{ + type: 'chart', + id: 'revenue_chart', + label: 'Revenue Trend', + properties: { chartType: 'line', object: 'opportunity', + categoryField: 'close_date', valueField: 'amount' }, + events: { onClick: "navigate_to('opportunity_detail', { id: $event.id })" }, + visibility: "os.user.profile == 'sales_manager'", +} +``` + +| Property | Type | Description | +|:--|:--|:--| +| `type` | `string` | Standard component type or a custom string | +| `id` | `string` | Unique component instance id | +| `properties` | `object` | Component-specific configuration | +| `events` | `object` | Event handlers (action expressions) | +| `visibility` | `string` | CEL visibility predicate | +| `style` / `className` | — | Ad-hoc CSS | +| `responsiveStyles` | `object` | **Preferred** styling channel: desktop-first per-breakpoint style maps (`large` base, then `medium` / `small` / `xsmall` overrides), compiled to scoped CSS — prefer design tokens like `var(--space-8)` | + +The standard, namespaced component types: + +| Namespace | Types | +|:--|:--| +| Structure | `page:header`, `page:footer`, `page:sidebar`, `page:tabs`, `page:accordion`, `page:card`, `page:section` | +| Record context | `record:details`, `record:highlights`, `record:related_list`, `record:activity`, `record:chatter`, `record:path`, `record:alert`, `record:quick_actions`, `record:reference_rail`, `record:history` | +| Navigation | `app:launcher`, `nav:menu`, `nav:breadcrumb` | +| Utility | `global:search`, `global:notifications`, `user:profile` | +| AI | `ai:chat_window`, `ai:suggestion` | +| Elements | `element:text`, `element:number`, `element:image`, `element:divider`, `element:button`, `element:filter`, `element:form`, `element:record_picker`, `element:text_input` | + +Components may also carry `dataSource` (per-element object binding for +multi-object pages), `responsive`, and `aria` configuration. Custom +string types are accepted for project-specific widgets. + +## Variables + +Pages hold local state shared across components: + +```ts +variables: [ + { name: 'selected_tab', type: 'string', defaultValue: 'overview' }, + { name: 'date_range', type: 'object', defaultValue: { start: null, end: null } }, +] +``` + +`type` accepts `'string'` (default), `'number'`, `'boolean'`, `'object'`, +`'array'`, or `'record_id'`. + +> A `record`-type page is the supported path for bespoke record layouts — +> reach for it when the roles-derived detail page (see +> [Forms](/docs/build/interface/forms)) isn't enough. Compose +> `record:highlights` in a full-width header, `record:details` and +> `record:activity` in a sidebar, and `record:related_list` components in +> the main region. + +Surface a page from an [app](/docs/build/interface/apps) with a `page` +navigation entry, or make it the app's landing page via `homePageId`. + +## Doc pages + +A **doc** is a page of package documentation: plain Markdown files in a +flat `src/docs/` directory, compiled into the package artifact and +rendered in the console at `/docs/`. Docs also ground the AI +assistant when it answers questions about your package. + +``` +src/docs/ + crm_index.md → doc name "crm_index" + crm_user_guide.md → doc name "crm_user_guide" +``` + +The filename stem becomes the doc `name` — no directory taxonomy, no +ordering file. The flat layout keeps cross-references stable: links +resolve by basename, never by path. + +| Rule | Detail | +|:--|:--| +| Naming | `snake_case`, and the build lint requires a **namespace prefix** (`crm_user_guide`, not `user_guide`) | +| Frontmatter | Optional; `title` and `description` are read. Title resolves: frontmatter → first `#` heading → doc name | +| Cross-references | Plain relative links (`[overview](./crm_index.md)`) — rewritten to `/docs/` in console, native on GitHub. **Broken same-package links fail the build** | +| Markdown | CommonMark + GFM: tables, fenced code with highlighting, heading anchors, GitHub alerts (`> [!TIP]`) | +| Rejected at build | **MDX / embedded components** (docs are data, not code — a trust boundary) and **image references** (no asset service yet; fail fast beats broken images) | + +Doc resolution is package-scoped: two installed packages may each ship a +doc with the same bare name and coexist — neither overwrites the other. + +> For dynamic content — a live flow diagram, a record table — don't try +> to embed a component. Link to the metadata by URL; the platform renders +> the live view, the doc just points at it. + +## Where to go next + +| Page | Why | +|:--|:--| +| [Apps](/docs/build/interface/apps) | Put pages in navigation, set `homePageId` | +| [Views](/docs/build/interface/views) | The object surfaces pages embed | +| [Forms](/docs/build/interface/forms) | Roles-derived record layouts vs custom record pages | +| [Dashboards](/docs/build/interface/dashboards) | Analytics-focused layout instead of free-form | +| [Actions](/docs/build/interface/actions) | `element:button` targets and `modal`-type actions | +| [AI Builder](/docs/build/ai-builder) | Generate page metadata by chat | From 3c0774ac493dc333e7deaa73c81d3b19bf279790 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:36:14 +0000 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20expand=20administrator=20docs=20?= =?UTF-8?q?=E2=80=94=20Setup=20console,=20users=20&=20org,=20access=20mana?= =?UTF-8?q?gement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Configure pages: Administration landing (Setup console tour and task map), Users & Organization (business units, invitations, teams, service accounts), Managing Access (onboarding/offboarding recipes), Field-Level Security, and Connect AI tools via MCP. Sidebar metas updated across all locales. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0145MY7RdJr8KuRMyn5oKAR6 --- content/docs/configure/index.mdx | 70 +++++++ content/docs/configure/mcp.mdx | 175 ++++++++++++++++ content/docs/configure/meta.de.json | 13 +- content/docs/configure/meta.es.json | 13 +- content/docs/configure/meta.fr.json | 13 +- content/docs/configure/meta.ja.json | 13 +- content/docs/configure/meta.json | 2 +- content/docs/configure/meta.ko.json | 13 +- content/docs/configure/meta.zh-Hans.json | 2 +- .../permissions/field-level-security.mdx | 163 +++++++++++++++ .../configure/permissions/managing-access.mdx | 186 ++++++++++++++++++ .../docs/configure/permissions/meta.de.json | 7 +- .../docs/configure/permissions/meta.es.json | 7 +- .../docs/configure/permissions/meta.fr.json | 7 +- .../docs/configure/permissions/meta.ja.json | 7 +- content/docs/configure/permissions/meta.json | 2 +- .../docs/configure/permissions/meta.ko.json | 7 +- .../configure/permissions/meta.zh-Hans.json | 2 +- content/docs/configure/users.mdx | 173 ++++++++++++++++ 19 files changed, 781 insertions(+), 94 deletions(-) create mode 100644 content/docs/configure/index.mdx create mode 100644 content/docs/configure/mcp.mdx create mode 100644 content/docs/configure/permissions/field-level-security.mdx create mode 100644 content/docs/configure/permissions/managing-access.mdx create mode 100644 content/docs/configure/users.mdx diff --git a/content/docs/configure/index.mdx b/content/docs/configure/index.mdx new file mode 100644 index 0000000..6b6ca02 --- /dev/null +++ b/content/docs/configure/index.mdx @@ -0,0 +1,70 @@ +--- +title: Administration +description: Where system administrators manage users, access, settings, and integrations — and which page answers which task. +--- + +# Administration + +This section is for the **system administrator** of an ObjectOS +deployment: the person who onboards users, grants access, wires up +sign-in, email, storage, and integrations, and keeps the system healthy. +You manage people and their permissions day to day; the applications, +objects, and permission sets themselves ship with the platform and the +app packages you install. + +> **Permissions are designed in Studio, assigned in Setup.** Most +> administration never leaves Setup — you enter Studio only to author +> permission sets or to run the explain engine. + +## The Setup console + +The built-in administration console lives at **`/apps/setup`** and +requires the `setup.access` permission. Its left-hand navigation is +contributed at runtime by the capability plugins that are loaded, so the +menu you see reflects exactly what your deployment runs — a disabled +capability contributes nothing and its group stays empty. + +The stable navigation groups: + +| Group | What you find there | +|---|---| +| **Overview** | System Overview dashboard | +| **People & Organization** | Users, Business Units, Teams, Organizations, Invitations | +| **Access Control** | Positions, Permission Sets, Sharing Rules, Record Shares, API Keys | +| **Approvals** | Approval processes (when the approvals plugin is loaded) | +| **Configuration** | All Settings, Branding, Authentication, Email, File Storage, AI & Embedder, Knowledge, Feature Flags | +| **Diagnostics** | Sessions, Notification Events, Audit Logs | +| **Integrations** | Webhooks | +| **Advanced** | OAuth Applications, Signing Keys (JWKS), Identity Links, User Preferences | + +## Task map + +| I need to… | Read | +|---|---| +| Add users, build the org tree, manage teams | [Users & Organization](/docs/configure/users) | +| Onboard, offboard, or change someone's access | [Managing Access](/docs/configure/permissions/managing-access) | +| Understand the whole access model | [Permissions](/docs/configure/permissions) | +| Configure sign-in, OAuth, SSO, 2FA | [Authentication](/docs/configure/authentication) | +| Change runtime and tenant settings | [System Settings](/docs/configure/system-settings) | +| Set up transactional email delivery | [Email](/docs/configure/email) | +| Configure file storage (S3, local disk) | [Storage](/docs/configure/storage) | +| Connect external business databases | [Data Sources](/docs/configure/data-sources) | +| Use the REST API and API keys | [API Access](/docs/configure/api-access) | +| Deliver outbound webhooks | [Webhooks](/docs/configure/webhooks) | +| Configure AI providers, embedders, RAG | [AI Service](/docs/configure/ai) | +| Connect Claude or another MCP client | [Connect AI Tools (MCP)](/docs/configure/mcp) | +| Configure artifact loading, database, cache | [Runtime Configuration](/docs/configure/runtime) | +| Plan backups and disaster recovery | [Backup](/docs/operate/backup) | +| Upgrade or roll back safely | [Upgrade](/docs/operate/upgrade) | +| Harden for production | [Production Readiness](/docs/operate/production) | +| Read logs, metrics, and audit trails | [Observability](/docs/operate/observability) | +| Diagnose a broken deployment | [Troubleshooting](/docs/operate/troubleshooting) | + +## Where to go next + +| Task | Page | +|---|---| +| Your first admin task: add people and structure | [Users & Organization](/docs/configure/users) | +| Give a new hire access | [Managing Access](/docs/configure/permissions/managing-access) | +| Learn the layered access model | [Permissions](/docs/configure/permissions) | +| Pre-production checklist | [Production Readiness](/docs/operate/production) | diff --git a/content/docs/configure/mcp.mdx b/content/docs/configure/mcp.mdx new file mode 100644 index 0000000..a0279c0 --- /dev/null +++ b/content/docs/configure/mcp.mdx @@ -0,0 +1,175 @@ +--- +title: Connect AI Tools (MCP) +description: Point Claude Code, Claude Desktop, or any MCP client at your ObjectOS app and let agents work with your data under your permission model. +--- + +# Connect AI Tools (MCP) + +Every ObjectOS deployment is already an MCP server. The runtime serves +the [Model Context Protocol](https://modelcontextprotocol.io) at +**`/api/v1/mcp`** — on by default, no plugin to install, no +configuration step. Your objects and exposed actions become typed tools +the moment you define them; the only thing left to do is connect a +client and prove it works. + +> To turn the surface off, set `OS_MCP_SERVER_ENABLED=false` — the +> endpoint then returns 404 and the **Setup → Connect an Agent** page +> disappears with it. + +This page is about connecting *external* AI tools to your app. For the +server-side AI stack — chat providers, embedders, RAG, and registering +the MCP server plugin explicitly in code — see +[AI Service](/docs/configure/ai). + +## Claude Code (one command) + +Interactive clients use OAuth — each deployment is its own OAuth 2.1 +authorization server, so there are no admin-minted credentials to pass +around. The first tool call opens a browser login and you connect **as +yourself**: + +```bash +# local dev server +claude mcp add --transport http my-app http://localhost:3000/api/v1/mcp + +# a deployed instance +claude mcp add --transport http my-app https://your-deployment.example.com/api/v1/mcp +``` + +For headless use (CI, containers) skip OAuth and attach an +[API key](#headless-api-keys) instead: + +```bash +claude mcp add --transport http my-app https://your-deployment.example.com/api/v1/mcp \ + --header "x-api-key: osk_..." +``` + +## Claude Desktop and claude.ai + +**Settings → Connectors → Add custom connector**, then paste the MCP +URL (`https://your-deployment.example.com/api/v1/mcp`). The first use +walks through the same browser login. + +## Any MCP client (`.mcp.json`) + +Clients that read an `mcpServers` map connect the same way. With an API +key: + +```json +{ + "mcpServers": { + "objectstack": { + "type": "http", + "url": "https://your-deployment.example.com/api/v1/mcp", + "headers": { "x-api-key": "osk_..." } + } + } +} +``` + +## Headless: API keys + +Mint a key from **Setup → Connect an Agent** (which also shows +copy-paste-ready connect snippets per client), or over REST: + +```bash +curl -b cookies.txt -X POST https://your-deployment.example.com/api/v1/keys +# → { "key": "osk_..." } — shown once; store it in your secret manager +``` + +Send it on every request in any of three equivalent forms: + +| Header | Example | +|---|---| +| `x-api-key` | `x-api-key: osk_...` | +| `Authorization: ApiKey` | `Authorization: ApiKey osk_...` | +| `Authorization: Bearer` | `Authorization: Bearer osk_...` (recognized by the `osk_` prefix) | + +> OAuth requires TLS — plain-HTTP deployments (except `localhost`) fall +> back to **API-key-only**: the browser-login track is disabled rather +> than allowed to run insecurely. + +For a long-lived integration, bind the key to a dedicated service user +with a minimal permission set — see +[Service accounts & API keys](/docs/configure/users#service-accounts--api-keys). + +## What the agent gets + +Ten data and action tools, generated from your metadata: + +| Tool | What it does | +|---|---| +| `list_objects` / `describe_object` | Discover which objects exist and their fields | +| `query_records` / `get_record` | Read data (list queries are capped at 50 rows per page by default) | +| `aggregate_records` | Grouped aggregation (registered when the active driver supports it) | +| `create_record` / `update_record` / `delete_record` | Write data | +| `list_actions` / `run_action` | Discover and invoke your business actions by name | + +Two exposure rules to know: + +- **Objects are exposed automatically** — except `sys_*` system + objects, which are blocked fail-closed. +- **Actions require the author's opt-in**: `ai: { exposed: true }` plus + an `ai.description` of at least 40 characters, and the action must be + callable without a UI (`script` with a body or registered handler, or + `flow`). + +## Permission enforcement + +- **Every call runs as the caller.** The MCP bridge resolves the same + execution context as a REST request, so object permissions, + [record access](/docs/configure/permissions/record-access), and + [field-level security](/docs/configure/permissions/field-level-security) + apply to the agent exactly as they do to a person in the UI. Sparse + results or denied writes usually mean governance is *working*, not + that the connection is broken. +- **OAuth scopes narrow the toolset.** Tokens carry `data:read`, + `data:write`, and `actions:execute` scopes — tools outside the + granted scopes are not even registered for that session. API-key and + session callers get the full set, still permission-checked per call. +- **Action bodies run as trusted app code** once invoked (the + `ai.exposed` gate and `requiredPermissions` are checked at *invoke* + time). Treat writing an action as a code-review-worthy act — that's + the real security boundary. +- An action can also declare `ai.requiresConfirmation`; + destructive-looking actions default to requiring it. + +## Verify the connection + +Ask the agent something only the live schema can answer: + +```text +What objects does this app have, and what fields does the main one carry? +``` + +You should see `list_objects` and `describe_object` fire. The natural +working pattern for an agent is `list_objects` → `describe_object` → +`query_records` → `run_action` — if all four work, the connection is +fully operational. + +> Agents work noticeably better with the app's **skill file**: download +> it from `GET /api/v1/mcp/skill`, or install the +> [official Claude plugin](https://github.com/objectstack-ai/claude-plugin) +> (`claude plugin marketplace add objectstack-ai/claude-plugin`), which +> bundles the skill and a guided `/objectstack:connect` command. + +## Troubleshooting + +| Symptom | Cause → fix | +|---|---| +| `404` on `/api/v1/mcp` | The surface is disabled — unset `OS_MCP_SERVER_ENABLED` (default is on) | +| `501 Not Implemented` | The MCP plugin isn't part of this build — check your stack's plugins | +| `401` on every call | Anonymous or invalid credentials. Interactive clients: complete the browser login. Headless: check the `osk_` key and header spelling | +| `403 insufficient_scope` | The OAuth token lacks the scope for that tool family (e.g. writes without `data:write`) — reconnect and grant the scope | +| An action is missing from `list_actions` | `ai.exposed` is not `true`, `ai.description` is shorter than 40 characters, the type isn't headless-callable (`url` / `modal` / `form` never appear), it targets a `sys_*` object, or the caller fails its `requiredPermissions` | +| Reads return few rows / writes denied | Working as designed — the caller's permissions and record access apply. Verify with the same user in the UI | + +## Where to go next + +| Task | Page | +|---|---| +| Configure AI providers, embedders, and the MCP server plugin | [AI Service](/docs/configure/ai) | +| Create service users and API keys | [Users & Organization](/docs/configure/users) | +| Understand what the agent is allowed to see | [Permissions](/docs/configure/permissions) | +| REST API and key management | [API Access](/docs/configure/api-access) | +| Verify a specific user's access | [Managing Access](/docs/configure/permissions/managing-access) | diff --git a/content/docs/configure/meta.de.json b/content/docs/configure/meta.de.json index 2d6493d..7d0adda 100644 --- a/content/docs/configure/meta.de.json +++ b/content/docs/configure/meta.de.json @@ -1,16 +1,5 @@ { "title": "Konfigurieren", "defaultOpen": false, - "pages": [ - "runtime", - "data-sources", - "authentication", - "permissions", - "storage", - "ai", - "system-settings", - "api-access", - "webhooks", - "email" - ] + "pages": ["index", "users", "runtime", "data-sources", "authentication", "permissions", "storage", "ai", "mcp", "system-settings", "api-access", "webhooks", "email"] } diff --git a/content/docs/configure/meta.es.json b/content/docs/configure/meta.es.json index 7aef632..a44bc2a 100644 --- a/content/docs/configure/meta.es.json +++ b/content/docs/configure/meta.es.json @@ -1,16 +1,5 @@ { "title": "Configurar", "defaultOpen": false, - "pages": [ - "runtime", - "data-sources", - "authentication", - "permissions", - "storage", - "ai", - "system-settings", - "api-access", - "webhooks", - "email" - ] + "pages": ["index", "users", "runtime", "data-sources", "authentication", "permissions", "storage", "ai", "mcp", "system-settings", "api-access", "webhooks", "email"] } diff --git a/content/docs/configure/meta.fr.json b/content/docs/configure/meta.fr.json index 7db55c6..97c61f8 100644 --- a/content/docs/configure/meta.fr.json +++ b/content/docs/configure/meta.fr.json @@ -1,16 +1,5 @@ { "title": "Configurer", "defaultOpen": false, - "pages": [ - "runtime", - "data-sources", - "authentication", - "permissions", - "storage", - "ai", - "system-settings", - "api-access", - "webhooks", - "email" - ] + "pages": ["index", "users", "runtime", "data-sources", "authentication", "permissions", "storage", "ai", "mcp", "system-settings", "api-access", "webhooks", "email"] } diff --git a/content/docs/configure/meta.ja.json b/content/docs/configure/meta.ja.json index 514da0f..4e1e8df 100644 --- a/content/docs/configure/meta.ja.json +++ b/content/docs/configure/meta.ja.json @@ -1,16 +1,5 @@ { "title": "設定", "defaultOpen": false, - "pages": [ - "runtime", - "data-sources", - "authentication", - "permissions", - "storage", - "ai", - "system-settings", - "api-access", - "webhooks", - "email" - ] + "pages": ["index", "users", "runtime", "data-sources", "authentication", "permissions", "storage", "ai", "mcp", "system-settings", "api-access", "webhooks", "email"] } diff --git a/content/docs/configure/meta.json b/content/docs/configure/meta.json index b2a622c..2315db4 100644 --- a/content/docs/configure/meta.json +++ b/content/docs/configure/meta.json @@ -1,5 +1,5 @@ { "title": "Configure", "defaultOpen": false, - "pages": ["runtime", "data-sources", "authentication", "permissions", "storage", "ai", "system-settings", "api-access", "webhooks", "email"] + "pages": ["index", "users", "runtime", "data-sources", "authentication", "permissions", "storage", "ai", "mcp", "system-settings", "api-access", "webhooks", "email"] } diff --git a/content/docs/configure/meta.ko.json b/content/docs/configure/meta.ko.json index 38b0529..e241b9a 100644 --- a/content/docs/configure/meta.ko.json +++ b/content/docs/configure/meta.ko.json @@ -1,16 +1,5 @@ { "title": "설정", "defaultOpen": false, - "pages": [ - "runtime", - "data-sources", - "authentication", - "permissions", - "storage", - "ai", - "system-settings", - "api-access", - "webhooks", - "email" - ] + "pages": ["index", "users", "runtime", "data-sources", "authentication", "permissions", "storage", "ai", "mcp", "system-settings", "api-access", "webhooks", "email"] } diff --git a/content/docs/configure/meta.zh-Hans.json b/content/docs/configure/meta.zh-Hans.json index d2dc0b0..c922ce4 100644 --- a/content/docs/configure/meta.zh-Hans.json +++ b/content/docs/configure/meta.zh-Hans.json @@ -1,5 +1,5 @@ { "title": "配置", "defaultOpen": false, - "pages": ["runtime", "data-sources", "authentication", "permissions", "storage", "ai", "system-settings", "api-access", "webhooks", "email"] + "pages": ["index", "users", "runtime", "data-sources", "authentication", "permissions", "storage", "ai", "mcp", "system-settings", "api-access", "webhooks", "email"] } diff --git a/content/docs/configure/permissions/field-level-security.mdx b/content/docs/configure/permissions/field-level-security.mdx new file mode 100644 index 0000000..1c74615 --- /dev/null +++ b/content/docs/configure/permissions/field-level-security.mdx @@ -0,0 +1,163 @@ +--- +title: Field-Level Security +description: Hide or lock down individual fields — grant semantics, server-side enforcement, and how FLS behaves in forms, views, and the API. +--- + +# Field-Level Security + +Field-level security (FLS) controls the visibility and editability of +individual fields, *after* object permissions and +[record access](/docs/configure/permissions/record-access) have allowed +the user to reach the record at all. It is the layer for "support can +see the account, but not its `annual_revenue`" and "reps can read the +external id but never change it". + +FLS rules live in [permission sets](/docs/configure/permissions/permission-sets) +— this page goes deeper on the grant semantics and enforcement than the +[field security appendix](/docs/configure/permissions/permission-sets#field-security-appendix) +there. + +## Field permission grants + +Field permissions are keyed `.` and use `readable` / +`editable`: + +```ts +fields: { + // Read-only: visible but not editable + 'account.annual_revenue': { readable: true, editable: false }, + 'account.description': { readable: true, editable: true }, + // Hidden: not visible at all + 'account.ssn': { readable: false, editable: false }, + 'opportunity.amount': { readable: true, editable: true }, + 'opportunity.probability': { readable: true, editable: false }, +} +``` + +The two flags produce three states: + +| State | Rule | Effect | +|---|---|---| +| **Hidden** | `{ readable: false, editable: false }` | Field is not visible at all — stripped from every response | +| **Read-only** | `{ readable: true, editable: false }` | Field is returned, but writes to it are rejected | +| **Editable** | `{ readable: true, editable: true }` | Field is visible and writable | + +> Always write field-permission keys **object-qualified** +> (`crm_lead.budget`, not `budget`) — since ObjectStack 14.4 the +> `security-fls-unqualified-key` lint rejects bare keys at compile +> time, because they silently matched nothing. + +## How grants combine + +FLS uses **default-visible (block-list) semantics**: fields without an +explicit rule pass through untouched — readable *and* writable. +Permission sets only constrain fields they explicitly enumerate. + +Field grants union **most-permissively** across a user's sets: one +set's `readable: true` out-votes another set's `false`. Until a +subtractive muting layer lands (reserved as ADR-0066 ⑧), a +`{ readable: false }` rule masks the field only as long as **no other +set the user holds** declares it `readable: true`. The practical +consequences: + +- Protect sensitive fields by granting them **only** in the sets that + need them — never rely on a `false` rule in one set to override a + `true` elsewhere. +- Treat a sensitive field on a broadly-granted object as a review + smell. + +Declared rules themselves are enforced fail-closed: a masked field is +stripped on read and a write to a non-editable field throws. + +## Enforcement in the API + +The SecurityPlugin middleware enforces field rules on the server, +regardless of how the request arrived — REST, ObjectQL, or any other +path. There is no back door through a lower-level API. + +**On read** — `find` / `findOne` results have non-readable fields +stripped from every record before the response leaves the engine. + +**On write** — `insert` / `update` requests are checked **before** the +operation reaches the driver. If the payload contains any field the +caller is not permitted to edit, the engine throws +`PermissionDeniedError` (HTTP 403) with the offending field names: + +```json +{ + "error": { + "code": "PERMISSION_DENIED", + "message": "[Security] Field write denied: not permitted to edit [salary, ssn] on 'employee'", + "details": { + "operation": "insert", + "object": "employee", + "forbiddenFields": ["salary", "ssn"] + } + } +} +``` + +**Why throw instead of silently stripping?** Silent strip hides the +security boundary from honest clients (their update "doesn't save" and +they cannot tell why) *and* gives a probing client no signal either +way. Throwing makes the boundary observable in both directions — +legitimate UIs get an actionable error to fix; probing clients learn +nothing they could not already infer. + +Two more enforcement details: + +- **Bulk inserts** are checked row-by-row; a single offending field in + any row rejects the whole batch atomically. +- **System operations** (`ExecutionContext { isSystem: true }`) bypass + the check entirely — used for migrations, seed loading, and audit-log + writes. + +## FLS in forms and views + +The generated forms and inline grids hide non-editable fields from the +UI — but that is a **UX layer only**. The server-side check above is +the source of truth, so behavior stays consistent everywhere: + +| Surface | Hidden field | Read-only field | +|---|---|---| +| Record forms / inline grids | Not rendered | Rendered without an editable control; a direct write attempt is rejected with 403 | +| List views, related lists, exports | Column value stripped from the response | Value shown normally | +| REST / ObjectQL | Stripped from results | Returned on read; write throws `PERMISSION_DENIED` with `forbiddenFields` | +| MCP / AI agents | Stripped — agents [run as the calling user](/docs/configure/mcp#permission-enforcement) | Same as REST | + +Because reads are stripped rather than errored, a hidden field simply +looks absent to that user — which is the point. + +## Verifying and reviewing FLS + +- **Per decision, at runtime** — the + [explain engine](/docs/configure/permissions#diagnose--audit) reports + the FLS layer's verdict alongside every other layer, with the + contributing permission set named. Use it when a user reports a + "missing" field. +- **Per change, at build time** — if your app has opted into the + access-matrix snapshot gate, `os compile` diffs the derived + (permission set × object) capability matrix against a committed + `access-matrix.json` and fails on drift, so capability changes ride + pull requests as reviewable semantic diffs: + +```bash +os compile --update-access-matrix +``` + +> Default to **hide** rather than read-only when the field carries +> sensitive data — read-only still leaks the value into responses and +> logs. Bundle field rules into permission sets that match a real job +> function, and pair them with audit-log retention for compliance +> cases. More authoring patterns: +> [Permission Sets](/docs/configure/permissions/permission-sets#field-security-appendix). + +## Where to go next + +| Task | Page | +|---|---| +| Author field rules inside permission sets | [Permission Sets](/docs/configure/permissions/permission-sets) | +| Control which rows are reachable at all | [Record Access](/docs/configure/permissions/record-access) | +| See the whole layered model | [Permissions](/docs/configure/permissions) | +| Verify a specific user's access | [Managing Access](/docs/configure/permissions/managing-access) | +| Check what AI agents can read | [Connect AI Tools (MCP)](/docs/configure/mcp) | diff --git a/content/docs/configure/permissions/managing-access.mdx b/content/docs/configure/permissions/managing-access.mdx new file mode 100644 index 0000000..8b2a624 --- /dev/null +++ b/content/docs/configure/permissions/managing-access.mdx @@ -0,0 +1,186 @@ +--- +title: Managing Access +description: The daily admin playbook — onboard a new hire, change a role, verify what someone can see, and offboard cleanly. +--- + +# Managing Access + +This is the task guide for the access questions an administrator +handles every week. The [model overview](/docs/configure/permissions) +explains *how* the layers work; this page tells you *what to click*. + +> **90% of daily administration is assigning people to positions.** The +> positions, the permission sets behind them, and the security baseline +> ship with the platform and the apps you install. You almost never +> build capability from scratch — and you shouldn't. + +A user's effective access is additive: + +```text +effective access = union of permission sets reached through positions + + direct grants + + the built-in member_default baseline +``` + +Restriction is done by *not granting* — there are no subtraction rules +to maintain. + +## Onboard a new hire + +Four ordered steps, each depending on the previous one: + +| Step | Where | What | +|---|---|---| +| 1. Create the user | Setup → People & Organization → Users | **Invite**, **Create**, or **Import** — see [Users & Organization](/docs/configure/users#add-people) | +| 2. Place them in the org tree | Same list → Business Unit Member row | User + business unit, one marked *primary*; depth-based visibility resolves through this | +| 3. Assign their position | Setup → Access Control → Positions | The daily 90% — see below | +| 4. Verify | Impersonate + Explain | Never announce "you're all set" untested — see below | + +### Assigning positions + +An assignment is one row: **User Position** (`sys_user_position`) = +*user* + *position* + optionally the **business unit** they hold it in. +The anchor decides *where* the position's depth grants apply — "sales +manager **of East**" sees East's records, not the whole company's. + +From **Setup → Access Control → Positions**, open a position and add +assignments from its related list (or create User Position rows +directly). These writes are governed: tenant-level admins pass; a +delegated admin can only assign allowlisted sets inside their own +subtree — self-escalation is structurally refused (see +[delegated administration](/docs/configure/permissions/permission-sets#delegated-administration)). + +Two adjacent surfaces complete the picture: + +- **Direct grants** — on a permission set's record page + (**Setup → Access Control → Permission Sets**), the **Assigned + Users** panel adds per-user grants without a position. Rows held *via + a position* are shown but removed on the position, not here. +- **Business-unit membership** — from step 2; independent of the + assignment anchor. + +## Change someone's role + +When someone changes departments or job functions: + +1. Update their **Business Unit Member** row to the new unit. +2. **Re-anchor their User Position rows** — remove or edit assignments + anchored to the old unit, add assignments for the new one. +3. Remove any direct grants that belonged to the old role. +4. Verify (below). + +Assignments may carry `valid_from` / `valid_until` windows — expired +grants stop resolving immediately, which is the clean way to handle a +planned transition or a temporary covering role. See +[Permission Sets](/docs/configure/permissions/permission-sets). + +## Verify what someone can see + +Two built-in verification tools: + +- **Impersonate.** Users list → row menu → **Impersonate User**. You + get that user's session — open the apps they'd open, confirm they see + what they should (and *don't* see what they shouldn't). + Impersonation is for legitimate support and verification; sessions + are logged. +- **Explain.** Studio's **Access** pillar → **Explain access**: pick + the user, an object, and an operation, and the engine returns the + verdict plus every evaluation layer — which permission set granted, + held via which position, which sharing rule widened, which policy + narrowed. + +The same report is available over REST: + +```bash +# GET, query-string form +curl -H "Authorization: Bearer $TOKEN" \ + "$BASE/api/v1/security/explain?object=crm_lead&operation=read&userId=usr_123" + +# POST, body form +curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"object":"crm_lead","operation":"read","userId":"usr_123"}' \ + "$BASE/api/v1/security/explain" +``` + +`operation` is one of `read | create | update | delete | transfer | +restore | purge` (defaults to `read`); omit `userId` to explain +**yourself**. Explaining yourself is always allowed; explaining +*another* user requires the `manage_users` capability or a delegated +admin scope covering that user. + +> When something resolves wrong, explain-first beats guess-and-toggle: +> the report names the exact permission set and layer to fix. See the +> [diagnose & audit section](/docs/configure/permissions#diagnose--audit). + +## Offboard someone + +1. **Ban User** — blocks sign-in immediately. This is the only urgent + step. +2. Remove their position assignments and direct grants at leisure. +3. Remove or re-parent their Business Unit Member rows. +4. Revoke any API keys bound to the user — keys act as the user and + keep working while the underlying grants exist. + +## When the shipped positions don't fit + +Permissions are *designed* in Studio, *assigned* in Setup. If no +shipped position covers the need, prefer the smallest change that +works, in this order: + +1. **Bind an existing permission set to a position.** +2. **Clone a shipped set and adjust it.** +3. **Author a new set** in Studio's permission-set matrix editor — a + structured spreadsheet for object CRUD, field-level security, and + capabilities; you never hand-write JSON. + +Editing a set that shipped with an app creates an *environment overlay* +— your change wins, survives upgrades, and can be reset back to the +vendor baseline. Keep the additive model in mind: author coherent +capability bundles, never "subtraction sets". See +[Permission Sets](/docs/configure/permissions/permission-sets). + +When translating a requirement into a change, pick the matching layer: + +| Requirement | Layer | Covered in | +|---|---|---| +| Can read/create/edit/delete an object type | Object permissions | [Permission Sets](/docs/configure/permissions/permission-sets) | +| Can see / edit a specific field | Field-level security | [Field-Level Security](/docs/configure/permissions/field-level-security) | +| Which *rows* a user sees | Record access | [Record Access](/docs/configure/permissions/record-access) | +| A functional capability (export, manage users) | System permissions | [Permission Sets](/docs/configure/permissions/permission-sets#system-permissions) | +| Can open an app or nav entry | Application access | [Permission Sets](/docs/configure/permissions/permission-sets) | + +## Quick paths + +| I need to… | Do this | +|---|---| +| Onboard a new employee | Invite/Create the user → Business Unit Member row → assign their position | +| Offboard someone | **Ban User** (blocks sign-in immediately) — then remove assignments at leisure | +| "I can't sign in" | User record → is it banned? locked? **Unlock Account** or **Set Password** (temporary, must-change) | +| "Why can't I see X?" | Studio → Access → **Explain access** for that user + object | +| Someone changed departments | Update their Business Unit Member row; re-anchor their User Position rows | +| Let a subsidiary manage its own staff | Grant a set carrying a [delegated admin scope](/docs/configure/permissions/permission-sets#delegated-administration) | +| Give one user one extra capability | Permission set record → **Assigned Users** → add (direct grant) | + +## Where is everything + +| Thing | Location | +|---|---| +| Users, invitations | Setup → People & Organization → Users / Invitations | +| Business-unit tree | Setup → People & Organization → Business Units | +| Teams | Setup → People & Organization → Teams | +| Positions, permission sets | Setup → Access Control | +| Sharing rules, record shares | Setup → Access Control | +| Password policy, MFA, lockout, SSO | Setup → Configuration → Authentication | +| Sessions, notification events, audit logs | Setup → Diagnostics | +| Permission matrix editor, Explain access | Studio → Access | + +## Where to go next + +| Task | Page | +|---|---| +| Create users and build the org tree | [Users & Organization](/docs/configure/users) | +| Understand positions and audience anchors | [Positions](/docs/configure/permissions/positions) | +| Author or adjust permission sets | [Permission Sets](/docs/configure/permissions/permission-sets) | +| Control which rows people see | [Record Access](/docs/configure/permissions/record-access) | +| Hide or lock down sensitive fields | [Field-Level Security](/docs/configure/permissions/field-level-security) | +| Sign-in policy and SSO | [Authentication](/docs/configure/authentication) | diff --git a/content/docs/configure/permissions/meta.de.json b/content/docs/configure/permissions/meta.de.json index 2dfb8cb..75fe5fd 100644 --- a/content/docs/configure/permissions/meta.de.json +++ b/content/docs/configure/permissions/meta.de.json @@ -1,9 +1,4 @@ { "title": "Berechtigungen", - "pages": [ - "index", - "roles", - "permission-sets", - "record-access" - ] + "pages": ["index", "managing-access", "positions", "permission-sets", "record-access", "field-level-security"] } diff --git a/content/docs/configure/permissions/meta.es.json b/content/docs/configure/permissions/meta.es.json index de91f14..bf5bfb1 100644 --- a/content/docs/configure/permissions/meta.es.json +++ b/content/docs/configure/permissions/meta.es.json @@ -1,9 +1,4 @@ { "title": "Permisos", - "pages": [ - "index", - "roles", - "permission-sets", - "record-access" - ] + "pages": ["index", "managing-access", "positions", "permission-sets", "record-access", "field-level-security"] } diff --git a/content/docs/configure/permissions/meta.fr.json b/content/docs/configure/permissions/meta.fr.json index 0c9223c..aca9a17 100644 --- a/content/docs/configure/permissions/meta.fr.json +++ b/content/docs/configure/permissions/meta.fr.json @@ -1,9 +1,4 @@ { "title": "Permissions", - "pages": [ - "index", - "roles", - "permission-sets", - "record-access" - ] + "pages": ["index", "managing-access", "positions", "permission-sets", "record-access", "field-level-security"] } diff --git a/content/docs/configure/permissions/meta.ja.json b/content/docs/configure/permissions/meta.ja.json index 689ed95..6dae3ab 100644 --- a/content/docs/configure/permissions/meta.ja.json +++ b/content/docs/configure/permissions/meta.ja.json @@ -1,9 +1,4 @@ { "title": "権限", - "pages": [ - "index", - "roles", - "permission-sets", - "record-access" - ] + "pages": ["index", "managing-access", "positions", "permission-sets", "record-access", "field-level-security"] } diff --git a/content/docs/configure/permissions/meta.json b/content/docs/configure/permissions/meta.json index b5af0e9..aca9a17 100644 --- a/content/docs/configure/permissions/meta.json +++ b/content/docs/configure/permissions/meta.json @@ -1,4 +1,4 @@ { "title": "Permissions", - "pages": ["index", "positions", "permission-sets", "record-access"] + "pages": ["index", "managing-access", "positions", "permission-sets", "record-access", "field-level-security"] } diff --git a/content/docs/configure/permissions/meta.ko.json b/content/docs/configure/permissions/meta.ko.json index 87f56be..099632b 100644 --- a/content/docs/configure/permissions/meta.ko.json +++ b/content/docs/configure/permissions/meta.ko.json @@ -1,9 +1,4 @@ { "title": "권한", - "pages": [ - "index", - "roles", - "permission-sets", - "record-access" - ] + "pages": ["index", "managing-access", "positions", "permission-sets", "record-access", "field-level-security"] } diff --git a/content/docs/configure/permissions/meta.zh-Hans.json b/content/docs/configure/permissions/meta.zh-Hans.json index b35f793..935e2d4 100644 --- a/content/docs/configure/permissions/meta.zh-Hans.json +++ b/content/docs/configure/permissions/meta.zh-Hans.json @@ -1,4 +1,4 @@ { "title": "权限", - "pages": ["index", "positions", "permission-sets", "record-access"] + "pages": ["index", "managing-access", "positions", "permission-sets", "record-access", "field-level-security"] } diff --git a/content/docs/configure/users.mdx b/content/docs/configure/users.mdx new file mode 100644 index 0000000..d685602 --- /dev/null +++ b/content/docs/configure/users.mdx @@ -0,0 +1,173 @@ +--- +title: Users & Organization +description: Build the business-unit tree, add and invite users, manage memberships and teams, and provision service accounts. +--- + +# Users & Organization + +Everything about *who exists* in your deployment — people, the org tree +they sit in, the teams they collaborate in, and the service accounts +that act on their behalf — is managed from +**Setup → People & Organization** (`/apps/setup`). + +The underlying identity objects (`sys_user`, `sys_organization`, +`sys_member`, `sys_business_unit`, `sys_team`, `sys_invitation`, +`sys_api_key`, …) live in your project database; see the +[identity layer table](/docs/configure/permissions#layer-1--identity) +for what each one represents. + +> **90% of daily administration is assigning people to positions.** The +> positions, the permission sets behind them, and the security baseline +> ship with the platform and the apps you install. Your job is the +> people side — covered on this page — and the assignments, covered in +> [Managing Access](/docs/configure/permissions/managing-access). + +## Build the organization (business units) + +**Setup → People & Organization → Business Units.** + +The business-unit tree is the *one* hierarchy in the access model: it +decides visibility depth (`unit`, `unit_and_below`) and delegated-admin +boundaries. Build it at onboarding, revisit it only on reorgs. + +- The default **Org Chart** tab renders the tree as an indented + expand-and-collapse tree grid. +- Create the root first (kind `company`), then add children with + **New**, picking the **Parent Business Unit** in the form. The kinds + `division` / `department` / `office` / `cost_center` are display + hints — the tree works the same regardless. +- Reorganize by **Edit**-ing a unit and changing its parent. The tree + view is a read-only presentation — re-parenting happens through the + record form, not drag-and-drop. + +Three similarly-named things, three different jobs — don't mix them up: + +| Object | Job | +|---|---| +| **Business unit** (`sys_business_unit`) | The hierarchy. Drives visibility depth and delegated-admin boundaries | +| **Team** (`sys_team`) | Flat collaboration group. Teams *receive* sharing; they never carry capability | +| **Organization** (`sys_organization`) | The tenant itself — your company's account, not a node inside it | + +> Keep the tree shallow and honest to your real structure. If something +> should affect *what records people see* by org structure, it's a +> business unit; if it's just a group people share records with, it's a +> team. + +## Add people + +**Setup → People & Organization → Users.** Three ways in, all +first-class: + +| Path | When to use | What happens | +|---|---|---| +| **Invite User** (toolbar) | The person has a reachable email | Sends an invitation; they set their own password on accept | +| **Create User** (toolbar) | No email flow wanted — or phone-only staff | Creates a sign-in-ready account (email and/or phone); a generated temporary password is shown **once**, with password change forced on first login | +| **Import** (toolbar) | Bulk onboarding from CSV/Excel | Wizard with column mapping and dry-run preview; up to 500 rows per batch. Pick the sign-in policy: no password (first sign-in via OTP/magic/reset link), send invitations, or one-time temporary passwords | + +Pending invitations are listed under +**Setup → People & Organization → Invitations**. + +The same flows exist over REST (ObjectStack 14.3+) for scripted +onboarding: + +```bash +# Create a sign-in-ready account with an optional one-time password +curl -X POST https://crm.example.com/api/v1/auth/admin/create-user + +# Bulk-import rows / CSV / XLSX (max 500 rows), with dry-run and upsert modes +curl -X POST https://crm.example.com/api/v1/auth/admin/import-users +``` + +Payloads, password policies, and phone-only accounts are covered in +[Authentication → Admin user management](/docs/configure/authentication#admin-user-management-objectstack-143). + +## Place people in the org tree (memberships) + +Placing a person in the org tree is a separate record: a **Business +Unit Member** row (`sys_business_unit_member` — user + business unit, +one marked *primary*). Depth-based visibility and +`unit_and_subordinates` sharing resolve through this membership, so +don't skip it — a user without a membership row is invisible to +depth-based rules. + +When someone changes departments, update their Business Unit Member row +and re-anchor their position assignments — see +[Managing Access](/docs/configure/permissions/managing-access). + +## Teams + +**Setup → People & Organization → Teams.** Teams are flat collaboration +groups: sharing rules and record shares can target them, but they never +carry permission sets. Use them when a cross-department group needs to +see a set of records without changing anyone's job function or the org +tree. + +## User lifecycle + +Day-to-day lifecycle actions live on each user's row menu and record +header: + +| Action | Effect | +|---|---| +| **Ban / Unban** | Blocks (or restores) sign-in immediately | +| **Unlock Account** | Clears a brute-force lockout early | +| **Set Password** | Sets a password directly; also mints one-time temporary passwords with forced rotation | +| **Impersonate User** | Opens a session as that user, for support and verification (sessions are logged) | + +To deactivate someone, **Ban** them first — sign-in is blocked +immediately — then remove their position assignments and memberships at +leisure. The full offboarding sequence is in +[Managing Access](/docs/configure/permissions/managing-access). + +> Users' own self-service password recovery depends on a configured +> [email](/docs/configure/email) or SMS delivery service. Until one is +> wired up, admin **Set Password** (temporary, must-change) is the +> reliable fallback. + +## Service accounts & API keys + +Integrations and headless agents authenticate with an API key +(`sys_api_key`) — a long-lived programmatic credential **bound to a +user**. Every call made with the key runs as that user, with that +user's permissions. + +For anything beyond personal scripting, create a dedicated service +user: + +1. **Create User** for the integration (no invite needed). +2. Assign it the smallest permission set that covers the integration's + objects — never an admin set. +3. Mint an API key for it, from **Setup → Connect an Agent** or over + REST: + +```bash +curl -b cookies.txt -X POST https://crm.example.com/api/v1/keys +# → { "key": "osk_..." } — shown once; store it in your secret manager +``` + +Key usage, headers, and rotation are covered in +[API Access](/docs/configure/api-access); connecting AI agents with a +key is covered in [Connect AI Tools (MCP)](/docs/configure/mcp). + +## FAQ + +**A new user sees almost nothing — broken?** No: that's the baseline +working. Every authenticated user gets the additive `member_default` +baseline; real access arrives when you assign a position. + +**Department vs. team?** Department = business unit (hierarchy, +visibility). Team = flat sharing group. + +**Can I delete the built-in permission sets?** No — sets like +`member_default` and `organization_admin` are the platform baseline. +Shipped sets can be overlaid or simply left unassigned. + +## Where to go next + +| Task | Page | +|---|---| +| Assign positions and permission sets | [Managing Access](/docs/configure/permissions/managing-access) | +| Understand the layered access model | [Permissions](/docs/configure/permissions) | +| Configure sign-in, SSO, and password policy | [Authentication](/docs/configure/authentication) | +| Set up email so invites and resets deliver | [Email](/docs/configure/email) | +| API keys for integrations | [API Access](/docs/configure/api-access) | From 51371d33cbaf39708dc11ca062267fda838d457a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:46:08 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20add=20Use=20section=20=E2=80=94=20e?= =?UTF-8?q?nd-user=20guide=20verified=20against=20the=20live=20Console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New top-level Use section for everyday users: console tour, working with records, views, dashboards, approvals, notifications, and profile/settings. Content was verified by driving the showcase app's Console in a browser. Landing page gains a three-audience directory (use / build / administer) and the sidebar orders sections by role. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0145MY7RdJr8KuRMyn5oKAR6 --- content/docs/index.mdx | 11 +++ content/docs/meta.json | 3 +- content/docs/use/approvals.mdx | 102 +++++++++++++++++++++++++++ content/docs/use/dashboards.mdx | 87 +++++++++++++++++++++++ content/docs/use/index.mdx | 107 +++++++++++++++++++++++++++++ content/docs/use/meta.de.json | 4 ++ content/docs/use/meta.es.json | 4 ++ content/docs/use/meta.fr.json | 4 ++ content/docs/use/meta.ja.json | 4 ++ content/docs/use/meta.json | 4 ++ content/docs/use/meta.ko.json | 4 ++ content/docs/use/meta.zh-Hans.json | 4 ++ content/docs/use/notifications.mdx | 86 +++++++++++++++++++++++ content/docs/use/profile.mdx | 87 +++++++++++++++++++++++ content/docs/use/records.mdx | 99 ++++++++++++++++++++++++++ content/docs/use/views.mdx | 97 ++++++++++++++++++++++++++ 16 files changed, 706 insertions(+), 1 deletion(-) create mode 100644 content/docs/use/approvals.mdx create mode 100644 content/docs/use/dashboards.mdx create mode 100644 content/docs/use/index.mdx create mode 100644 content/docs/use/meta.de.json create mode 100644 content/docs/use/meta.es.json create mode 100644 content/docs/use/meta.fr.json create mode 100644 content/docs/use/meta.ja.json create mode 100644 content/docs/use/meta.json create mode 100644 content/docs/use/meta.ko.json create mode 100644 content/docs/use/meta.zh-Hans.json create mode 100644 content/docs/use/notifications.mdx create mode 100644 content/docs/use/profile.mdx create mode 100644 content/docs/use/records.mdx create mode 100644 content/docs/use/views.mdx diff --git a/content/docs/index.mdx b/content/docs/index.mdx index ffc20cc..85da709 100644 --- a/content/docs/index.mdx +++ b/content/docs/index.mdx @@ -69,6 +69,17 @@ ObjectOS never phones home. No telemetry. No license server. Air-gapped networks are a first-class deployment target — see [Air-gapped](/docs/deploy/air-gapped). +## Find your docs + +This documentation serves three audiences — start at the section that +matches how you use ObjectOS: + +| You are … | You want to … | Start here | +|---|---|---| +| **An everyday user** | Work in the apps: records, views, dashboards, approvals | [Use](/docs/use) | +| **A builder** | Create apps: data models, interfaces, automation, AI agents | [Build](/docs/build) | +| **An administrator** | Run the platform: users, permissions, settings, deployment | [Administration](/docs/configure), [Deploy](/docs/deploy), [Operate](/docs/operate/production) | + ## Where to go next | If you want to … | Read | diff --git a/content/docs/meta.json b/content/docs/meta.json index f9ebc3f..c00e70b 100644 --- a/content/docs/meta.json +++ b/content/docs/meta.json @@ -6,9 +6,10 @@ "extend-existing-systems", "quickstart", "architecture", - "deploy", + "use", "build", "configure", + "deploy", "operate", "resources", "reference" diff --git a/content/docs/use/approvals.mdx b/content/docs/use/approvals.mdx new file mode 100644 index 0000000..d425451 --- /dev/null +++ b/content/docs/use/approvals.mdx @@ -0,0 +1,102 @@ +--- +title: Approvals +description: Submit records for sign-off, act on requests assigned to you, and always know where every approval stands. +--- + +# Approvals + +Some records need a sign-off before they move forward — an expense over a +limit, a discount, a leave request. When that happens, ObjectOS creates an +**approval request**: a live, system-managed record that tracks that one +submission from the moment it's sent until someone approves or rejects it. +You never create or edit these tracking records yourself — the system keeps +them up to date as decisions are made. + +This page covers both sides of the process: submitting something for +approval, and acting on requests that are waiting on you. + +## Your approval inbox + +Everything approval-related lives in one place: **Account → Inbox → +Approvals**. Open the **Account** app (it's available to everyone), expand +**Inbox** in the left sidebar, and pick **Approvals**. + +The list has view tabs across the top, so you can slice the same inbox four +ways: + +| View tab | What it shows | +|---|---| +| **My Pending** | Requests waiting on *you* to decide. This is your to-do list. | +| **I Submitted** | Requests you sent for approval — check here to see where your own submissions stand. | +| **Completed** | Requests that have been decided, approved or rejected. | +| **All** | Every approval request you're allowed to see. | + +When there's nothing waiting on you, **My Pending** shows the empty state +*"No pending approvals — You're all caught up."* That's the screen you want +to see at the end of the day. + +> **Tip:** Bookmark the Approvals inbox, or just glance at the **Needs your +> attention** list on the console home — pending approvals appear there too, +> with how long they've been waiting. + +## What an approval request is + +| It is… | It is not… | +|---|---| +| A live instance tracked per submission — one request per record sent for approval | A copy of the record itself | +| System-managed — created, updated, and closed automatically as decisions happen | Something you edit by hand | +| Your audit trail — who submitted, who's assigned, what was decided, and when | A one-time notification that disappears | + +Because each submission gets its own request, resubmitting a record after a +rejection starts a fresh request — the old one stays in **Completed** as +history. + +## Acting on a request + +When a request is routed to you, two things happen: + +1. **You get a notification** — the bell in the top bar shows an unread + badge, and the item appears in **Needs your attention** on the console + home and in **Account → Inbox → Notifications**. +2. **The record shows approval actions for you** — open the record in + question and you'll see **Approve** and **Reject** as record actions. + These buttons appear only for the assigned approver; other people viewing + the record won't see them. + +| To… | Do this | +|---|---| +| See what's waiting on you | **Account → Inbox → Approvals** → **My Pending**, or the bell / **Needs your attention** | +| Review the details | Open the record from the request — read the highlights and field sections like any other record | +| Decide | Use the **Approve** or **Reject** record action | +| Confirm it went through | The request moves to **Completed**, and the submitter is notified | + +> **Tip:** Read the record, not just the request. The approval request tells +> you *that* something needs a decision; the record itself tells you +> *whether* to approve it. + +## Records locked while pending + +Depending on how the approval is configured, a record may be **locked** +while its request is pending — fields can't be edited until a decision is +made. This protects the approver: what they approve is exactly what was +submitted, with no quiet edits in between. If you need to change a locked +record, ask the approver to reject it (or withdraw it, where your app +allows), make your changes, and resubmit. + +## App-provided review queues + +Some apps add their own curated **review-queue page** — for example an +"Approvals" page in the app's navigation that shows just the items relevant +to that team, pre-filtered and ready to work through. Use it when it's +there; it's the same underlying requests, just a friendlier lens. Your +**Account → Inbox → Approvals** inbox always remains the complete, +everything-in-one-place view. + +## Where to go next + +| What now | Read | +|---|---| +| Tour the console as an everyday user | [Using ObjectOS](/docs/use) | +| Work with the records you're approving | [Records](/docs/use/records) | +| Keep up with everything else routed to you | [Notifications](/docs/use/notifications) | +| Slice lists the way approvers do | [Views](/docs/use/views) | diff --git a/content/docs/use/dashboards.mdx b/content/docs/use/dashboards.mdx new file mode 100644 index 0000000..c341106 --- /dev/null +++ b/content/docs/use/dashboards.mdx @@ -0,0 +1,87 @@ +--- +title: Dashboards +description: Read your team's KPIs, charts, and tables at a glance — and narrow them by date or filter in two clicks. +--- + +# Dashboards + +A **dashboard** turns your records into numbers and charts: how many +tasks are open, how work is trending, who's carrying the load. You +don't build dashboards here — you read them; they update themselves +as records change. + +## Where dashboards live + +Dashboards are items in an app's **navigation sidebar**, usually +inside a group such as **Analytics**. Click one and it opens like any +other page. + +## The dashboard header + +At the top of every dashboard: + +| Element | What it does | +|---|---| +| **Title and description** | What this dashboard measures and why | +| **Date-range preset** | A dropdown like **Last 90 days** — changes the time window for every widget at once | +| **Dashboard filters** | Dropdowns like **Task Status: All** — narrow all widgets to a slice (e.g. only *In Progress*) | + +> **Tip:** If a number looks wrong, check the date range and filters +> first — "Last 90 days" with **Task Status: Done** shows a very +> different picture than all-time, all statuses. + +Date range and filters apply to the **whole dashboard**, so every +widget answers the same question about the same slice of data — you +never have to wonder whether two charts are counting different things. + +## Widget types + +A dashboard is a grid of **widgets**. You'll meet three kinds: + +| Widget | Looks like | Good for | +|---|---|---| +| **KPI stat tile** | One big number with a label | Headline figures — *Open Tasks: 42* | +| **Chart** | Bar, pie, and similar visuals | Comparing groups and spotting trends | +| **Table** | Rows and columns | The detail behind a number — top items, recent records | + +A typical layout puts a row of stat tiles at the top (the headlines), +charts in the middle (the shape of the data), and tables at the bottom +(the receipts). Read it in that order and you'll have the story in +under a minute. + +## Dashboards vs. views + +Both show your records — from different altitudes: + +| Surface | Shows | Use it when | +|---|---|---| +| **Dashboard** | Aggregates — counts, totals, trends | You want the big picture | +| **View** | Individual records, row by row | You want to work the list — see [Using views](/docs/use/views) | + +## Empty states + +A widget with nothing to show says so plainly — for example a table +displaying **No rows**. That's not an error: with the current date +range and filters, no records match. Widen the range or reset a +filter and the data usually reappears. + +## From number to records + +Dashboards summarize records, and some widgets let you **drill +through** to them: where a tile, chart segment, or table row is +clickable, it opens the matching records so you can act on what the +number is telling you. From there you're in a normal list — see +[Using views](/docs/use/views). + +> **Tip:** Reading a dashboard is a loop: spot the odd number, narrow +> with a filter, drill through to the records, fix what needs fixing. + +## Where to go next + +| I want to… | Read | +|---|---| +| Work with the records behind the numbers | [Working with records](/docs/use/records) | +| Filter and group a list myself | [Using views](/docs/use/views) | +| Handle requests waiting on me | [Approvals](/docs/use/approvals) | +| Manage what notifications I get | [Notifications](/docs/use/notifications) | +| Back to the Console tour | [Using ObjectOS](/docs/use) | diff --git a/content/docs/use/index.mdx b/content/docs/use/index.mdx new file mode 100644 index 0000000..2f57368 --- /dev/null +++ b/content/docs/use/index.mdx @@ -0,0 +1,107 @@ +--- +title: Using ObjectOS +description: Sign in once and find every app, record, and notification your team shares — a five-minute tour of the Console. +--- + +# Using ObjectOS + +ObjectOS is where your team's business apps live — projects, tasks, +approvals, dashboards. You sign in once and see every app you have +access to, in one place, with one search box. + +This section is for **everyday users**. If you build apps or +administer the system, head to [Build](/docs/build) or +[Configure](/docs/configure) instead. + +## Signing in + +Open the URL your team gave you and sign in with your **email and +password**. Your session is remembered, so you stay signed in on that +browser until you log out from the avatar menu. + +## The Console home + +After signing in you land on the home page. Top to bottom: + +| Area | What it shows | +|---|---| +| **Greeting** | A personal hello ("Good afternoon, Ada") | +| **Your apps** | A grid of tiles — one per app you have access to. Click a tile to open the app. | +| **Needs your attention** | Pending notifications and approvals, with how long ago each arrived | +| **Recently Accessed** | Objects and records you opened recently — the fastest way back to yesterday's work | +| **Activity feed** | Recent activity across your workspace | + +> **Tip:** Treat **Needs your attention** as your morning inbox — it +> surfaces approvals waiting on you before you open any app. + +## Switching between apps + +The **breadcrumb** in the top-left corner always tells you where you +are: *app → object → record*. The first segment is an **app +switcher** — click it to drop down a list of your apps and jump +between them without going back home. + +Every install has, alongside the business apps built for your team: + +| App | Who sees it | What it's for | +|---|---|---| +| **Account** | Everyone | Your personal space — profile, notifications inbox, approvals, security | +| **Setup** | Admins only | System administration | + +## The app navigation sidebar + +Inside an app, the left sidebar is that app's menu. It contains flat +items and **collapsible groups** (for example *Workspace* or +*Analytics*). Each item opens one of three things: + +| Item type | What clicking it opens | +|---|---| +| An object (e.g. *Tasks*) | Its list views — see [Views](/docs/use/views) | +| A page | A custom page built for your app | +| A dashboard | Charts and KPIs — see [Dashboards](/docs/use/dashboards) | + +A **pin icon** appears next to the active item so you can pin the +places you use most. + +## Global search + +Press `⌘K` (Mac) or `Ctrl-K` (Windows/Linux), or click **Search…** in +the top bar. A dialog opens that searches **records across all your +objects** — type a few letters of a task name, project, or customer +and jump straight to it. + +## The top bar + +The top bar is visible on every screen: + +| Control | What it does | +|---|---| +| **Search…** | Opens global search (`⌘K` / `Ctrl-K`) | +| **Bell** | Notifications, with a red badge showing your unread count. The full inbox lives in **Account → Inbox → Notifications**. | +| **?** | Help | +| **Avatar** | Your account menu (below) | + +The **avatar menu** shows your name and email, and contains: + +| Entry | What it does | +|---|---| +| **Profile** | Opens your profile in the Account app | +| **Preferences → Theme** | Switch between light and dark | +| **Preferences → Language** | Change the interface language | +| **Log out** | End your session | + +> **Tip:** Theme and language live in the **avatar menu → Preferences**, +> not on your Profile page — a common place to look first. + +## Where to go next + +| I want to… | Read | +|---|---| +| Open, create, and edit records | [Working with records](/docs/use/records) | +| Switch between grid, kanban, calendar and more | [Using views](/docs/use/views) | +| Read charts and KPIs | [Dashboards](/docs/use/dashboards) | +| Approve or reject requests | [Approvals](/docs/use/approvals) | +| Manage my notifications | [Notifications](/docs/use/notifications) | +| Update my name, photo, or password | [Profile & security](/docs/use/profile) | +| Build or customize apps | [Build](/docs/build) | +| Administer the system | [Configure](/docs/configure) | diff --git a/content/docs/use/meta.de.json b/content/docs/use/meta.de.json new file mode 100644 index 0000000..f33b00b --- /dev/null +++ b/content/docs/use/meta.de.json @@ -0,0 +1,4 @@ +{ + "title": "Verwenden", + "pages": ["index", "records", "views", "dashboards", "approvals", "notifications", "profile"] +} diff --git a/content/docs/use/meta.es.json b/content/docs/use/meta.es.json new file mode 100644 index 0000000..e75916c --- /dev/null +++ b/content/docs/use/meta.es.json @@ -0,0 +1,4 @@ +{ + "title": "Usar", + "pages": ["index", "records", "views", "dashboards", "approvals", "notifications", "profile"] +} diff --git a/content/docs/use/meta.fr.json b/content/docs/use/meta.fr.json new file mode 100644 index 0000000..d8bc3e3 --- /dev/null +++ b/content/docs/use/meta.fr.json @@ -0,0 +1,4 @@ +{ + "title": "Utiliser", + "pages": ["index", "records", "views", "dashboards", "approvals", "notifications", "profile"] +} diff --git a/content/docs/use/meta.ja.json b/content/docs/use/meta.ja.json new file mode 100644 index 0000000..bbf57db --- /dev/null +++ b/content/docs/use/meta.ja.json @@ -0,0 +1,4 @@ +{ + "title": "使い方", + "pages": ["index", "records", "views", "dashboards", "approvals", "notifications", "profile"] +} diff --git a/content/docs/use/meta.json b/content/docs/use/meta.json new file mode 100644 index 0000000..76d5829 --- /dev/null +++ b/content/docs/use/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Use", + "pages": ["index", "records", "views", "dashboards", "approvals", "notifications", "profile"] +} diff --git a/content/docs/use/meta.ko.json b/content/docs/use/meta.ko.json new file mode 100644 index 0000000..cd4aa52 --- /dev/null +++ b/content/docs/use/meta.ko.json @@ -0,0 +1,4 @@ +{ + "title": "사용", + "pages": ["index", "records", "views", "dashboards", "approvals", "notifications", "profile"] +} diff --git a/content/docs/use/meta.zh-Hans.json b/content/docs/use/meta.zh-Hans.json new file mode 100644 index 0000000..6075968 --- /dev/null +++ b/content/docs/use/meta.zh-Hans.json @@ -0,0 +1,4 @@ +{ + "title": "使用", + "pages": ["index", "records", "views", "dashboards", "approvals", "notifications", "profile"] +} diff --git a/content/docs/use/notifications.mdx b/content/docs/use/notifications.mdx new file mode 100644 index 0000000..635ff45 --- /dev/null +++ b/content/docs/use/notifications.mdx @@ -0,0 +1,86 @@ +--- +title: Notifications +description: Catch everything routed to you — approvals, digests, and updates — without living in your inbox. +--- + +# Notifications + +ObjectOS tells you when something needs you: an approval lands on your desk, +a scheduled digest summarizes your projects, a record you follow changes. +Those messages surface in three places, from quickest glance to full +history. + +| Surface | Where | Best for | +|---|---|---| +| **The bell** | Top bar, on every screen | A quick "anything new?" check — the red badge shows your unread count | +| **Needs your attention** | Console home | Your morning triage — recent items with how long they've been waiting | +| **The full inbox** | **Account → Inbox → Notifications** | Reading, searching, and working through everything | + +## The bell and the home page + +The **bell icon** sits in the top bar wherever you are in the console. A red +badge on it counts your unread notifications — click it to see what's new +without leaving your current screen. + +The console home adds a **Needs your attention** list: pending +notifications and approvals, each with its age, so you can see at a glance +what's newest and what's been waiting longest. + +> **Tip:** Start your day on the console home. **Needs your attention** plus +> **Recently Accessed** is usually all the triage you need before diving in. + +## The full inbox + +For the complete picture, open **Account → Inbox → Notifications** — the +**Account** app in your app switcher, then **Inbox** in the sidebar. The +default view shows your own notifications as a list: + +| Column | What it tells you | +|---|---| +| **Title** | What happened, in one line | +| **Topic** | What kind of message it is — e.g. a project digest vs. an approval | +| **Severity** | How urgent it is (Info and up) | +| **Created At** | When it arrived | + +It's a normal list view, so everything you know about lists applies: filter +it, sort it, search within it. + +## Where notifications come from + +You don't configure any of this — notifications arrive because something in +your apps decided you should know: + +| Source | Example | +|---|---| +| **Automations** | A scheduled digest that summarizes your open project tasks every morning | +| **Approvals** | A request is assigned to you to decide, or your own submission gets approved or rejected | +| **Subscriptions** | An object or record you follow changes | + +## Muting a noisy object + +If one object generates more noise than signal for you, mute it: open that +object's list and click the **bell-off toggle** in the list header. You'll +stop receiving notifications for that object; click it again to unmute. +Muting is per object and only affects *you* — your teammates keep getting +theirs. + +> **Tip:** Mute rather than ignore. A muted object stays fully usable — you +> just stop being pinged about it — and your unread badge goes back to +> meaning something. + +## Desktop notifications + +If you use the ObjectOS desktop app, it can mirror your in-app +notifications to your operating system's native notifications — the same +banners and notification center entries as any other desktop app. You'll +see new items even when the console window is in the background, and +clicking one takes you straight to it. + +## Where to go next + +| What now | Read | +|---|---| +| Act on the approvals your notifications point to | [Approvals](/docs/use/approvals) | +| Filter and sort your inbox like any list | [Views](/docs/use/views) | +| Adjust your personal settings | [Profile & settings](/docs/use/profile) | +| Tour the console as an everyday user | [Using ObjectOS](/docs/use) | diff --git a/content/docs/use/profile.mdx b/content/docs/use/profile.mdx new file mode 100644 index 0000000..0c7b995 --- /dev/null +++ b/content/docs/use/profile.mdx @@ -0,0 +1,87 @@ +--- +title: Profile & settings +description: Make ObjectOS yours — your name and avatar, your theme and language, and control over every signed-in session. +--- + +# Profile & settings + +Everything personal lives in two places: the **avatar menu** in the +top-right corner (quick preferences, available on every screen) and the +**Account** app (your profile, security, and inbox). Nothing here affects +anyone else — these are your settings, for your account. + +| I want to… | Go to | +|---|---| +| Change my name or avatar | **Account → Profile** | +| Change my password | **Account → Profile** → Change Password | +| Switch light/dark mode or language | Avatar menu → Preferences | +| See or sign out my other sessions | **Account → Security** → Active Sessions | +| Check which sign-in providers are linked | **Account → Security** → Linked Accounts | +| Sign out | Avatar menu → **Log out** | + +## Your profile + +Open **Account → Profile** — either through the **Account** app in your app +switcher, or via the avatar menu's **Profile** entry. + +| Setting | Can you change it? | +|---|---| +| **Avatar** | Yes — click **Upload** and pick an image. Your teammates see it wherever you appear: assignments, activity feeds, approvals. | +| **Name** | Yes — edit it in the Personal Information card. | +| **Email** | No — your email identifies your account and cannot be changed here. | +| **Role** | Read-only — it shows what your administrator assigned you. | + +Click **Save Changes** to apply your edits. + +Below Personal Information sits the **Change Password** card: enter your +current password and the new one, and you're done. Your other devices stay +signed in until their sessions end — sign them out from **Active Sessions** +if you want a clean sweep. + +## Preferences: theme and language + +Click your avatar in the top-right corner. Alongside your name and email +you'll find the Preferences: + +| Preference | Options | +|---|---| +| **Theme** | Light or dark — the whole console follows immediately. | +| **Language** | Pick your display language for the console UI. | + +Both take effect on the spot; no save button, no reload. + +## Security + +Open **Account → Security** for the two things worth checking now and then: + +| Section | What it's for | +|---|---| +| **Linked Accounts** | The social or company sign-in providers (SSO) connected to your account — see what's linked at a glance. | +| **Active Sessions** | Every device and browser currently signed in as you. Review the list and **sign out** any session you don't recognize or no longer use. | + +> **Tip:** Signed in on a shared or borrowed machine and forgot to log out? +> You don't need that machine — revoke the session from **Active +> Sessions** on any device. + +## Also in the Account app + +| Section | What it's for | +|---|---| +| **Inbox → My Organizations** | The organizations your account belongs to. | +| **Inbox → Notifications / Approvals** | Your personal inbox — covered in [Notifications](/docs/use/notifications) and [Approvals](/docs/use/approvals). | +| **Developer → API Keys, OAuth Applications** | Credentials for connecting external tools to ObjectOS. **Most users never need this section** — if you're not sure whether you do, you don't. | + +## Logging out + +Open the avatar menu and click **Log out**. That ends the session on the +device you're using; other devices keep their own sessions until you sign +them out from **Active Sessions**. + +## Where to go next + +| What now | Read | +|---|---| +| Keep your inbox under control | [Notifications](/docs/use/notifications) | +| Handle requests routed to you | [Approvals](/docs/use/approvals) | +| Get productive with your data | [Records](/docs/use/records) | +| Tour the console as an everyday user | [Using ObjectOS](/docs/use) | diff --git a/content/docs/use/records.mdx b/content/docs/use/records.mdx new file mode 100644 index 0000000..c3c9ecd --- /dev/null +++ b/content/docs/use/records.mdx @@ -0,0 +1,99 @@ +--- +title: Working with records +description: Open, read, create, and edit the records behind your daily work — tasks, projects, customers, anything. +--- + +# Working with records + +Everything in ObjectOS is a **record** — a task, a project, a +customer, an invoice. This page shows you how to open a record, read +its page, create new ones, and edit many at once. + +## Opening a record + +In any list, the **record's title is a link** — click it to open the +record page. To get back, use the back link at the top of the record +(for example **< All Tasks**), or the breadcrumb in the top-left +corner. + +## Anatomy of a record page + +From top to bottom, a record page shows: + +| Element | What it is | +|---|---| +| **Back link** | Returns you to the list you came from (e.g. **< All Tasks**) | +| **State pipeline bar** | Only on objects with a workflow — chevron stages showing where the record is in its process | +| **Highlights strip** | The record's key fields at a glance (e.g. Project, Assignee, Priority, Due Date, Progress) | +| **Action buttons** | Actions defined for this record, such as **Log Time** | +| **Field sections** | The rest of the fields, grouped into cards (e.g. Overview, Schedule, Details) | +| **Show N empty fields** | A toggle at the end of a section — empty fields are collapsed by default to keep the page short | + +### Reading the state pipeline + +When an object has a workflow, the bar at the top shows every stage +as a chevron: completed stages carry a checkmark (✓ Backlog → ✓ To Do +→ ✓ In Progress), and the **current stage is highlighted**. One +glance tells you how far along the record is. + +> **Tip:** If a record looks sparse, click **Show N empty fields** — +> the fields exist, they just have no value yet. + +## Creating a record + +Click **+ New** at the top of any list. A modal opens (for example +*"Create Task — Add a new Task to your database"*). Inside the form: + +| You'll see | How it works | +|---|---| +| Fields marked `*` | Required — you can't save without them | +| **Select…** pickers | Lookup fields that point to another record (a Project, a User). Type to search, or use the table-browse button to pick from a full list. | +| Dropdowns | Choice fields like Status or Priority — pick one value | +| Date pickers | Click a date field to pick a day from a calendar | + +Finish with **Create**, or **Cancel** to discard. + +## Editing records inline + +You don't have to open every record to change it. In a grid view, +turn on the **Edit inline** toggle in the toolbar — cells become +editable, so you can fix a status, reassign a task, or update a date +directly in the list, spreadsheet-style. + +## The per-row actions menu + +Every row in a grid has a `⋮` **Actions** menu at its end. It holds +the actions available for that single record — a quick alternative to +opening the record first. + +## Selecting many records at once + +Each grid row starts with a **checkbox**: + +1. Tick the rows you want (or the header checkbox for the whole page). +2. The object's **bulk actions** — buttons like **Reassign…** at the + top of the list — now apply to everything you selected. + +> **Tip:** Bulk actions differ per object; they're the buttons next to +> **+ New** and **Import** in the list header. + +## The list header, in full + +Above every list you'll find: + +| Button | What it does | +|---|---| +| **+ New** | Create a record (the modal described above) | +| **Import** | Bring in records from a file instead of typing them one by one | +| Custom bulk actions | Object-specific operations like **Reassign…**, applied to your selection | +| Bell-off toggle | Mute notifications for this object — see [Notifications](/docs/use/notifications) | + +## Where to go next + +| I want to… | Read | +|---|---| +| Change how the list itself looks — filter, group, switch to kanban | [Using views](/docs/use/views) | +| See totals and trends instead of rows | [Dashboards](/docs/use/dashboards) | +| Approve a record waiting on me | [Approvals](/docs/use/approvals) | +| Control which updates reach me | [Notifications](/docs/use/notifications) | +| Back to the Console tour | [Using ObjectOS](/docs/use) | diff --git a/content/docs/use/views.mdx b/content/docs/use/views.mdx new file mode 100644 index 0000000..4aa29fa --- /dev/null +++ b/content/docs/use/views.mdx @@ -0,0 +1,97 @@ +--- +title: Using views +description: See the same records as a grid, board, calendar, or timeline — and filter, group, and sort them your way. +--- + +# Using views + +A **view** is a saved way of looking at an object's records. The same +tasks can appear as a spreadsheet-style grid, a kanban board, or a +calendar — you switch freely, and nothing about the data changes. + +## View tabs + +Across the top of every list you'll see the object's views as tabs, +for example: + +**All Tasks ★ | In Progress | Urgent | Done | … 9 more | +** + +| Tab element | Meaning | +|---|---| +| **★** | The default view — what opens first | +| Named tabs (*In Progress*, *Urgent*) | Pre-filtered views set up for your team. A small funnel badge on a tab means it carries built-in filters. | +| **… 9 more** | Overflow menu when there are more views than fit | +| **+** | Create your own **personal view** — your filters, your columns, saved just for you | + +> **Tip:** Instead of re-applying the same filter every morning, click +> **+** once and save it as a personal view. + +## Switching view types + +The toolbar on the right shows the current view type (e.g. **Grid**). +Click it to switch between six ways of seeing the same records: + +| Type | Best for | +|---|---| +| **Grid** | The everyday table — scanning, sorting, and editing many records | +| **Kanban** | Work in progress — cards moving through stages on a board | +| **Gallery** | Records where the picture matters — products, designs, people | +| **Calendar** | Anything with a date — deadlines, events, bookings, by month or week | +| **Timeline** | A chronological feed of records over time | +| **Gantt** | Project plans — bars showing start, end, and duration | + +## The view toolbar + +Next to the view-type switcher: + +| Control | What it does | +|---|---| +| **Edit inline** | Toggle spreadsheet-style cell editing in the grid — see [Working with records](/docs/use/records) | +| **Filter** | Show only records matching conditions you choose | +| **Group** | Cluster rows under headings (e.g. by Project or Assignee) | +| **Sort** | Order records; a badge shows how many sort rules are active | +| Column settings | Choose which columns appear and in what order | +| Magnifier | **In-view search** — type to narrow the current list | + +> **Tip:** In-view search filters the list you're on; `⌘K` / `Ctrl-K` +> searches records across every object. Use the magnifier when you +> know *which* list, and `⌘K` when you don't. + +## Reading a grid + +Grids pack extra signals into their cells: + +| You'll see | It means | +|---|---| +| Row checkboxes and `#` numbers | Select rows for bulk actions; count rows at a glance | +| Colored pills | Values of choice fields like Status or Priority — the color follows the value | +| Red dates ("Overdue 6d") | The date has passed — overdue by that many days | +| Progress bars | Percent-complete fields drawn as bars | +| `⋮` at row end | The per-row actions menu | +| Footer count ("10 records") | How many records the current view contains | + +## Kanban boards + +Switch to **Kanban** and records become **cards in columns**. The +columns come from one of the object's choice fields — for tasks, +typically Status, so you get *Backlog*, *To Do*, *In Progress*, *In +Review*, and so on: + +| Board element | What it shows | +|---|---| +| Column header | The stage name and a **count** of cards in that column | +| Card | The record's title plus key details — assignee, a priority pill | +| Toolbar | The same **Filter / Group / Sort** controls as the grid | + +Kanban answers "how much work is at each stage?" in one look — column +counts are your bottleneck detector. + +## Where to go next + +| I want to… | Read | +|---|---| +| Open and edit the records behind a view | [Working with records](/docs/use/records) | +| See aggregated numbers instead of rows | [Dashboards](/docs/use/dashboards) | +| Act on approval requests | [Approvals](/docs/use/approvals) | +| Tune what the bell tells me | [Notifications](/docs/use/notifications) | +| Back to the Console tour | [Using ObjectOS](/docs/use) |