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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions content/docs/build/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ lacks either one, because such a flow would register and never run.
| `schedule`, time-relative *(16.0)* | A date field's proximity to today, via a daily sweep | "Remind me 60 days before the contract ends" |
| `autolaunched` | An action button, another flow, or an API call | "Approve invoice" actions |
| `screen` | A user, through one or more screens | Guided data entry |
| `api` | An HTTP request | An endpoint for an external system |
| `api` | An HTTP request, signed with the start node's `config.secret` | An endpoint for an external system |

## Record change: react to a record change

Expand Down Expand Up @@ -163,6 +163,22 @@ time-triggered flow has no caller to inherit an organization from;
ObjectStack's [Flow metadata](https://docs.objectstack.ai/docs/automation/flows#the-acting-organization)
says when its start node must declare one with `organization`.

> **Scheduled work is off until the deployment turns it on.** Since
> ObjectStack 17.5, a time-triggered flow (a `schedule` cadence, or the
> time-relative sweep below) and a packaged `defineJob` cron job arm only
> when the deployment sets `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` to `true`
> (or `1`, `on`, `yes`). Unset, nothing arms, and each such flow is reported
> as *disabled by deployment policy*, not as an error. Off is the ObjectStack
> runtime's default; whether your ObjectOS image or its deploy-bundle
> template sets the variable for you is not something this page can promise,
> so set it explicitly when your app depends on scheduled flows, and confirm
> it with `os doctor`, which prints the effective value — see
> [Environment Variables](/docs/reference/environment-variables#runtime-services).
> The platform's own scheduled work (approval escalation, retention, message
> dispatch) is not affected. Under the `isolated` tenancy posture, a
> scheduled flow that does not declare `organization` on its start node is
> not armed even with the switch on.

## Time-relative: fire relative to a date field (16.0)

"Remind me 60 days before the contract ends" used to be authored as a
Expand Down Expand Up @@ -295,6 +311,15 @@ under the same names; only declared inputs are bound. The caller's
identity is forwarded into the run, so the update runs under the caller's
permissions.

The route answers with an HTTP status that says what happened, so branch
on the status rather than on a `success` flag in the body: `200` for a run
that dispatched (completed, paused, or [refused](#ending-a-run-completed-or-refused)),
`404` for an unknown flow, `409 FLOW_DISABLED` for a flow that is switched
off, `422 FLOW_NO_START_NODE` for a definition with no start node, and
`400 FLOW_FAILED` for a run that dispatched and failed. An action of type
`flow` answers the same way — see
[REST API → Flow status table](/docs/reference/rest-api#flow-status-table).

## Node types

| Node `type` | Purpose |
Expand All @@ -314,6 +339,34 @@ permissions.
| `screen` | Pause for a user's input |
| `subflow` | Call another flow |
| `approval` | Block until approvers decide (requires `@objectstack/plugin-approvals`) |
| `end` | End the run — `completed` by default, or `refused` with a reason (below) |

### Ending a run: completed or refused

Since ObjectStack 17.4 an `end` node declares the run's **outcome**. A flow
that has to say no — a duplicate, an over-limit amount — ends on a refusing
`end` instead of failing or showing a screen that only says so:

```ts
// One flow node; the flow around it is omitted.
{
id: 'refuse_duplicate',
type: 'end',
label: 'Refused: duplicate',
config: {
outcome: 'refused', // 'completed' (the default) | 'refused'
message: 'Refused: {record.name} is already registered.',
},
}
```

A refused run is not a failed one: nothing threw. It ends with the run
status `refused`, the trigger route answers `200` with that status, and the
rendered `message` comes back as `refusalMessage`, with no
`successMessage` beside it. `message` is required when
`outcome` is `refused`, and refused on a completed end, where nothing
would ever show it. Reach the refusing `end` from a `decision` branch, and
keep every write on the branches the refusal does not take.

## Conditions and branches

Expand Down Expand Up @@ -358,7 +411,11 @@ export const expenseRouting = defineFlow({
A `decision` node branches on its out-edges. They are evaluated in the
order `edges` lists them, the first whose `condition` holds runs, and the
`isDefault` edge runs when none does. Without `isDefault`, an edge with no
condition runs on every pass, beside whichever branch matched.
condition runs on every pass, beside whichever branch matched. To run
*every* branch whose condition holds, declare `config: { mode: 'inclusive' }`
on the decision. First-match is the rule since ObjectStack 17.5; before it,
every true branch ran, and a decision stored in the database (one saved
from Studio) took the new meaning on upgrade without being rewritten.

## Error handling

Expand Down Expand Up @@ -413,6 +470,17 @@ declarative metadata — non-engineers can edit a flow, and it serializes
back to the same shape as the TypeScript you'd hand-author. It is
Studio's **Automations** pillar.

A flow saved from Studio is stored in the deployment, beside the flows
your installed packages ship. Two rules follow, both since ObjectStack
17.6:

- **A packaged flow wins its name.** A stored flow that shares its name
with a packaged flow is shadowed and does not run; the startup log warns
about it. To customize a packaged flow, clone it under a new name.
- **Switch a stored flow with its `status`.** The on/off toggle switches
packaged flows only. A flow authored in the deployment is switched off by
saving it with `status: 'obsolete'`, and back on with `status: 'active'`.

## Testing flows

```bash
Expand Down
45 changes: 40 additions & 5 deletions content/docs/build/data/validation-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ fields: {
| `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) |
| `readonly: true` | Server-enforced on insert and update — a non-system write to the field is dropped and reported in the response's `droppedFields`, or refused outright when the write passes the `strictReadonlyWrites` option. To seed a read-only field at create time, write as the system: a seed, a system hook, or a flow with `runAs: 'system'` |

Field types also validate themselves for free: `email` checks the
`local@domain` shape, `url` requires a protocol, `select` values must
Expand All @@ -66,7 +66,12 @@ po_number: Field.text({
```

`visibleWhen`, `readonlyWhen`, and `requiredWhen` all take a CEL
predicate — see [Formulas](/docs/build/data/formulas).
predicate — see [Formulas](/docs/build/data/formulas). They read the
record's own fields only. A `requiredWhen` or `readonlyWhen` that cannot
be evaluated — a misspelled field, ordering over `null`, a read through a
lookup such as `record.account.tier` — refuses the write with
`VALIDATION_FAILED` instead of letting it through; a check that has to
read a related record belongs in a [validation rule](#reading-a-related-record).

## Object-level validation rules

Expand Down Expand Up @@ -178,6 +183,35 @@ rule's `events` must include `insert` for the check to run.
In update-time conditions, `previous` holds the pre-change snapshot —
`record.stage != previous.stage` detects a change.

### Reading a related record

Since ObjectStack 17.5 a `script` or `cross_field` rule can read **one
hop** through a `lookup`, `master_detail`, `user` or `tree` field:

```ts
// One validation rule, an entry of the object's `validations`
{
name: 'partner_deal_cap',
type: 'script',
severity: 'error',
message: 'Partner deals over 10,000 need a direct contract',
condition: "record.account.type == 'partner' && record.amount > 10000",
events: ['insert', 'update'],
}
```

The engine loads only the related fields the condition names, in one
batched read per reference field, and reads nothing when no rule
traverses. A second hop (`record.account.owner.email`) is refused, and so
is comparing the reference's id while traversing it. Field-level
predicates and row-level security stay on the record's own fields.

A rule sees `record` and `previous` and nothing else: it cannot ask who is
saving. `current_user.can(...)` is answered elsewhere — see
[CEL → Permission checks](/docs/reference/cel#permission-checks) — and in a
validation rule it cannot be evaluated, so the rule refuses every write it
runs on.

## Uniqueness: use an index, not a rule

There is **no** `uniqueness` validation type — on purpose. A
Expand Down Expand Up @@ -217,9 +251,10 @@ form and in the API error response. Make it actionable:
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.
> **Warning:** A rule whose predicate cannot be evaluated (a parse error,
> a misspelled field, a value it cannot compare) **fails closed**: the
> write is refused with `VALIDATION_FAILED`, never saved with the rule
> skipped. Test a new rule against real records before you publish it.

## Best practices

Expand Down
203 changes: 0 additions & 203 deletions content/docs/build/data/validation-rules.zh-Hans.mdx

This file was deleted.

Loading
Loading