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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions content/docs/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,32 @@ their own pending request never sees Approve/Reject/Reassign (buttons the server
would 403 anyway), and a position-addressed approver is never wrongly hidden.
The service stays the sole authority — the predicate only trims the UI.

### Progress and notification deep links

A pending multi-approver request also carries a **server-computed
`decision_progress`** block, so the console shows real progress rather than a
client guess:

```jsonc
// getRequest, for a pending multi-approver request:
"decision_progress": {
"behavior": "per_group",
"got": 1, "need": 2, // unanimous / quorum: a running M-of-N tally
"groups": [ // per_group: one entry per approver group
{ "label": "manager", "got": 1, "need": 1 },
{ "label": "finance", "got": 0, "need": 1 }
]
}
```

It is computed from the open-time approver snapshot (OOO substitution applied),
so it reflects who **actually** still needs to sign — the console renders it as
per-group tick badges or a "2 of 3 · finance pending" count.

Approval **notifications deep-link into the request**: `notify()` rewrites the
inbox action URL to `/system/approvals?request=<id>`, so clicking the bell opens
that request's drawer directly instead of a generic list.

### Timeouts and escalation

`escalation` is real, not decorative: set `enabled: true` and `timeoutHours`,
Expand Down
2 changes: 1 addition & 1 deletion content/docs/automation/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ ctx = {
input, // mutable input — record fields exposed flat (raw wrapper: input.data, input.options)
result, // mutable operation result (after* events)
previous, // record state before the operation (update/delete)
session, // { userId, tenantId, positions, accessToken, isSystem }
session, // { userId, organizationId, positions, accessToken, isSystem }
user, // { id, name, email } convenience shortcut — reserved for future use;
// not currently set by the engine, use session.userId instead
transaction, // active transaction handle, if any
Expand Down
2 changes: 1 addition & 1 deletion content/docs/data-modeling/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@ These properties are available on **all** field types:
| `searchable` | `boolean` | `false` | Include in search index |
| `sortable` | `boolean` | `true` | Allow sorting by this field |
| `hidden` | `boolean` | `false` | Hide from default views |
| `readonly` | `boolean` | `false` | Prevent user edits |
| `readonly` | `boolean` | `false` | Server contract — non-system writes to this field are stripped on **both** INSERT and UPDATE (the value falls back to `defaultValue`), not just hidden in the UI (#2948 / #3043) |
| `externalId` | `boolean` | `false` | External system identifier |
| `defaultValue` | `any` | — | Default value for new records |
| `group` | `string` | — | Field grouping / section |
Expand Down
24 changes: 21 additions & 3 deletions content/docs/data-modeling/formulas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,25 @@ Every expression in metadata is persisted as the same envelope:

```ts
type Expression = {
dialect: 'cel' | 'js' | 'cron' | 'template';
dialect: 'cel' | 'cron' | 'template';
source?: string; // surface syntax
ast?: unknown; // parsed AST (filled by `objectstack compile`)
meta?: { rationale?: string; generatedBy?: string };
};
```

ObjectStack ships **four** registered dialects (M9.9):
ObjectStack ships **three** registered expression dialects:

| Dialect | Use for | Helper |
|:-----------|:-----------------------------------------------------|:--------------|
| `cel` | computed values, predicates, defaults, formulas | `` cel`...` `` / `` F`...` `` / `` P`...` `` |
| `cron` | recurring schedules (Job, ETL, sync, exports, jobs) | `` cron`...` `` |
| `template` | text interpolation (`{{path}}`) — notif/prompt/title | `` tmpl`...` `` |
| `js` | sandboxed L2 hook bodies (TypeScript source) | n/a |

The `js` **expression** dialect was retired in v16 (#3278, ADR-0058 addendum) —
it was a stub with no engine. Procedural JavaScript is unaffected: it remains the
**L2** authoring surface as a sandboxed `ScriptBody { language: 'js' }` in
hook/action bodies (a separate enum), not an expression dialect.

All dialects share the same variable scope (`record`, `previous`, `os.user`,
`os.org`, `os.env`, `vars` for flow steps), so you author once and never have
Expand Down Expand Up @@ -175,6 +179,7 @@ All functions are pure given a pinned `now`, which is what makes
| `matches(s, regex)` | bool | Regex test |
| `len(v)` | int | Length of a string / list / map (mirrors CEL's built-in `size()`) |
| `abs(x)` / `round(x)` | number | Numeric helpers |
| `floor(x)` / `ceil(x)` | number | Round toward −∞ / +∞ (`floor(-1.2) == -2`) — **not** integer-division's round-toward-zero |
| `min(a, b)` / `max(a, b)` | dyn | Smaller / larger operand (numeric comparison) |

Add new helpers in
Expand Down Expand Up @@ -211,6 +216,7 @@ come at two severities: **errors** fail the build; **warnings** are advisory
| **Field must exist** — a typo'd `record.<field>` is flagged with a did-you-mean. | `record.amont` → `record.amount` | error |
| **Unknown function** — a misspelled or nonexistent stdlib call. | `isBlnk(record.x)` | error |
| **Type soundness** — a text or boolean field used with an arithmetic (`+ - * / %`) or ordering (`< > <= >=`) operator against a number faults at runtime and evaluates to `null`. Store it as a number field, or drop the arithmetic. | `record.title * 2` | warning |
| **Date arithmetic** — a `date` / `datetime` field used with `+` / `-` against a number (a `YYYY-MM-DD` value is a string at runtime, so this always faulted to `null`). Use the date helpers instead. | `record.end_date - record.start_date + 1` → `daysBetween(record.start_date, record.end_date) + 1`; `today() + 30` → `daysFromNow(30)`; `record.date + n` → `addDays(record.date, n)` | error (#3306) |

> **Integer literals in arithmetic are fine.** `record.amount / 100`,
> `record.price * 2`, `record.total - fee` all work — the engine handles mixed
Expand Down Expand Up @@ -385,6 +391,18 @@ Field.formula({
})
```

<Callout type="info">
**`dateField == today()` now matches (#3183).** A `date` field reads back as a
`YYYY-MM-DD` string, and CEL treats a string and a timestamp as unequal — so the
natural "is it due today" predicate used to silently return `false`. The engine
now rewrites temporal `==` / `!=` comparisons (coercing the field operand with
`date(...)`), so `record.due_date == today()` matches on the calendar day. This
applies to formulas, defaults, validation rules, and hook/flow conditions.
Ordering (`< > <= >=`) and string equality (`record.d == "2026-06-20"`) were
always fine. This is the read-side counterpart to the date-arithmetic build
error above — equality is rewritten and works; `+`/`-` arithmetic is rejected.
</Callout>

### Financial Calculations

```typescript
Expand Down
2 changes: 1 addition & 1 deletion content/docs/kernel/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Every data hook is a single-argument handler `(ctx: HookContext) => void | Promi
| `ctx.input` | **Mutable** input. Shapes: insert `{ data }`, update `{ id, data }`, delete `{ id }`. Modify this to change the operation. |
| `ctx.result` | Operation result, available in `after*` events (mutable). |
| `ctx.previous` | Record state before the operation (update/delete). |
| `ctx.session` | Auth/tenancy info (`userId`, `tenantId`, `roles`, …). |
| `ctx.session` | Auth/tenancy info (`userId`, `organizationId`, `roles`, …). |
| `ctx.api` | Scoped cross-object data access. |

### Registering Data Hooks
Expand Down
1 change: 1 addition & 0 deletions content/docs/protocol/kernel/runtime-capabilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ The **Data Layer** capabilities define what query operations, data types, and bu
|-----------|---------|-------------|
| `transactions` | `true` | Database transaction support |
| `bulkOperations` | `true` | Bulk create/update/delete |
| `transactionalBatch` | — | Surfaced in **discovery** (`client.capabilities.transactionalBatch`, #3298): `true` iff the atomic cross-object batch route (`POST {basePath}/batch`, ADR-0034) is mounted **and** the engine can honour a transaction. Lets clients negotiate `client.data.batchTransaction(...)` at connect time instead of probing `404`/`405`/`501`. |

### Driver Support

Expand Down
15 changes: 15 additions & 0 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default defineStack({
| Picklist option labels | `objects.<name>.fields.<field>.options.<value>` |
| View titles, descriptions, empty states | `objects.<name>._views.<view>` |
| Action labels, confirm text, success messages | `objects.<name>._actions.<action>` |
| Action result dialogs (title / description / acknowledge / field labels) | `objects.<name>._actions.<action>.resultDialog` |
| Form sections | `objects.<name>._sections.<section>` |
| App navigation | `apps.<app>.navigation.<id>.label` |
| Dashboards and widgets | `dashboards.<name>` |
Expand All @@ -77,6 +78,20 @@ export default defineStack({
The metadata types resolved per request are **object, view, action, app, and
dashboard** — a field's labels are translated as part of its object document.

<Callout type="info">
**One-shot result dialogs are translatable (#3347).** The post-success
`resultDialog` shown by actions like `create_user` (temporary password), 2FA
backup codes, and OAuth client-secret rotation carries its own
`_actions.<action>.resultDialog` slot (`title` / `description` / `acknowledge`
and `fields` keyed by the literal result-field path, e.g. `"user.email"`).
`os i18n extract` emits these keys; the shipped platform dialogs ship
en / zh-CN / ja-JP / es-ES copy. Separately, **platform notification and
storage strings localize to the recipient's locale** — collaboration
assignment / @mention bell titles resolve in the locale of whoever reads the
bell (not the actor), and `sys_file` / `sys_upload_session` ship their own
bundles so the file-detail page and its status pipeline are localized (#3354).
</Callout>

## How a locale is chosen

Per request, in this order:
Expand Down
106 changes: 106 additions & 0 deletions examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1430,9 +1430,115 @@ export const ExpenseSignoffFlow = defineFlow({
],
});

/**
* Committee Quorum (#3266) — a `quorum` (M-of-N) approval, the collective
* sign-off complement to {@link ExpenseSignoffFlow}'s per-group 会签. A
* high-value expense report needs **any 2 of 3** committee members (manager /
* finance / legal) to approve; a single rejection still vetoes, and
* `minApprovals` clamps to the approver count so a misconfiguration can never
* deadlock. Where `per_group` requires one from EACH group, `quorum` counts a
* flat tally across all approvers.
*/
export const CommitteeQuorumFlow = defineFlow({
name: 'showcase_committee_quorum',
label: 'High-Value Expense — Committee Quorum',
description: 'Any 2-of-3 committee members approve a high-value expense report (#3266 quorum / M-of-N).',
type: 'autolaunched',
status: 'active',
nodes: [
{
id: 'start',
type: 'start',
label: 'On High-Value Submitted',
config: {
objectName: 'showcase_expense_report',
triggerType: 'record-after-update',
condition: 'status == "submitted" && previous.status != "submitted" && total_amount >= 5000',
},
},
{
id: 'committee',
type: 'approval',
label: 'Committee Sign-off (2 of 3)',
config: {
approvers: [
{ type: 'position', value: 'manager' },
{ type: 'position', value: 'finance' },
{ type: 'position', value: 'legal' },
],
behavior: 'quorum',
minApprovals: 2,
lockRecord: true,
},
},
{ id: 'approved', type: 'end', label: 'Approved' },
{ id: 'rejected', type: 'end', label: 'Rejected' },
],
edges: [
{ id: 'e1', source: 'start', target: 'committee' },
{ id: 'e2', source: 'committee', target: 'approved', label: 'approve' },
{ id: 'e3', source: 'committee', target: 'rejected', label: 'reject' },
],
});

/**
* Task Due Reminder (#1874) — a declarative `timeRelative` sweep, far more
* robust than a `record_change` flow gated on `due_date == daysFromNow(n)`
* (which fires only if the task happens to be edited on the exact threshold
* day). A daily sweep launches this flow **once per matching task** at T-minus
* 3 and 1 days before its `due_date`, with the task on the flow context. Swap
* `offsetDays` for `withinDays: 7` to nudge everything due within a week
* (negative = overdue lookback).
*/
export const TaskDueReminderFlow = defineFlow({
name: 'showcase_task_due_reminder',
label: 'Task Due Reminder',
description: 'Daily sweep: remind the owner 3 and 1 days before an open task is due (#1874 time-relative).',
type: 'schedule',
status: 'active',
runAs: 'system', // a sweep has no trigger user — elevate explicitly
nodes: [
{
id: 'start',
type: 'start',
label: 'Daily Sweep',
config: {
timeRelative: {
object: 'showcase_task',
dateField: 'due_date',
offsetDays: [3, 1], // — or — withinDays: 7 (negative = overdue lookback)
filter: { status: { $ne: 'done' } }, // optional, ANDed with the date window
},
// schedule defaults to daily 08:00 UTC; override with
// schedule: { type: 'cron', expression: '0 8 * * *' }
},
},
{
id: 'remind_owner',
type: 'notify',
label: 'Remind Owner',
config: {
topic: 'task.due_soon',
channels: ['inbox'],
severity: 'warning',
title: 'Task due soon: {record.title}',
message: 'Your task "{record.title}" is due on {record.due_date}.',
actionUrl: '/showcase_task',
},
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'remind_owner' },
{ id: 'e2', source: 'remind_owner', target: 'end' },
],
});

export const allFlows = [
TaskCompletedFlow,
ExpenseSignoffFlow,
CommitteeQuorumFlow,
TaskDueReminderFlow,
ReassignWizardFlow,
InquiryPurgeFlow,
BudgetApprovalFlow,
Expand Down
9 changes: 6 additions & 3 deletions examples/app-showcase/src/data/objects/project.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,12 @@ export const Project = ObjectSchema.create({
label: 'Project Status Flow',
description: 'Projects progress through valid status transitions.',
field: 'status',
// State machines validate *transitions*, so they run on update only —
// an insert sets the initial state (constrained by the select options).
events: ['update'] as const,
// `transitions` govern UPDATE; `initialStates` (#3165) is the FSM entry
// point on INSERT — without it a `select` would accept ANY option as the
// initial value, so a project could be born already `completed`. Requiring
// `insert` in `events` is what makes the initialStates check run on create.
events: ['insert', 'update'] as const,
initialStates: ['planned'],
message: 'Invalid project status transition.',
transitions: {
planned: ['active', 'cancelled'],
Expand Down
8 changes: 4 additions & 4 deletions skills/objectstack-data/references/data-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,9 +489,9 @@ interface HookContext {
// Execution context
session?: {
userId?: string;
organizationId?: string; // Active org — blessed name. Matches the
// `organization_id` column + `current_user.organizationId` (RLS)
tenantId?: string; // @deprecated alias of organizationId (identical value)
organizationId?: string; // Active org — the single blessed name. Matches the
// `organization_id` column + `current_user.organizationId` (RLS).
// The former `tenantId` alias was removed in #3290.
roles?: string[];
accessToken?: string;
};
Expand Down Expand Up @@ -524,7 +524,7 @@ predicates, and in seed rows. Read it as **`organizationId`**:
const org = ctx.user?.organizationId ?? ctx.session?.organizationId;
```

> The former `ctx.session.tenantId` alias was removed in v11 (#3290) — read the
> The former `ctx.session.tenantId` alias was removed in v16 (#3290) — read the
> org under `organizationId`. (The generic driver-layer `execCtx.tenantId` /
> `DriverOptions.tenantId` isolation knob is a separate axis and is unaffected.)

Expand Down
Loading