Skip to content

Commit d8b8354

Browse files
authored
docs + examples: bring guides and showcase current with 16.0 (#3359)
Docs: 5 stale-line fixes (tenantId→organizationId, readonly server contract, js dialect retired) + additive gaps (formula floor/ceil + date-arith build error + today() match, approvals decision_progress/deep-links, resultDialog i18n slot, transactionalBatch discovery capability). Examples (app-showcase): demonstrate quorum (2-of-3), time-relative trigger, and state_machine.initialStates. All shapes verified against @objectstack/spec; CI green.
1 parent d02a1b4 commit d8b8354

10 files changed

Lines changed: 182 additions & 13 deletions

File tree

content/docs/automation/approvals.mdx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,32 @@ their own pending request never sees Approve/Reject/Reassign (buttons the server
296296
would 403 anyway), and a position-addressed approver is never wrongly hidden.
297297
The service stays the sole authority — the predicate only trims the UI.
298298

299+
### Progress and notification deep links
300+
301+
A pending multi-approver request also carries a **server-computed
302+
`decision_progress`** block, so the console shows real progress rather than a
303+
client guess:
304+
305+
```jsonc
306+
// getRequest, for a pending multi-approver request:
307+
"decision_progress": {
308+
"behavior": "per_group",
309+
"got": 1, "need": 2, // unanimous / quorum: a running M-of-N tally
310+
"groups": [ // per_group: one entry per approver group
311+
{ "label": "manager", "got": 1, "need": 1 },
312+
{ "label": "finance", "got": 0, "need": 1 }
313+
]
314+
}
315+
```
316+
317+
It is computed from the open-time approver snapshot (OOO substitution applied),
318+
so it reflects who **actually** still needs to sign — the console renders it as
319+
per-group tick badges or a "2 of 3 · finance pending" count.
320+
321+
Approval **notifications deep-link into the request**: `notify()` rewrites the
322+
inbox action URL to `/system/approvals?request=<id>`, so clicking the bell opens
323+
that request's drawer directly instead of a generic list.
324+
299325
### Timeouts and escalation
300326

301327
`escalation` is real, not decorative: set `enabled: true` and `timeoutHours`,

content/docs/automation/hooks.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ ctx = {
8585
input, // mutable input — record fields exposed flat (raw wrapper: input.data, input.options)
8686
result, // mutable operation result (after* events)
8787
previous, // record state before the operation (update/delete)
88-
session, // { userId, tenantId, positions, accessToken, isSystem }
88+
session, // { userId, organizationId, positions, accessToken, isSystem }
8989
user, // { id, name, email } convenience shortcut — reserved for future use;
9090
// not currently set by the engine, use session.userId instead
9191
transaction, // active transaction handle, if any

content/docs/data-modeling/field-types.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,7 @@ These properties are available on **all** field types:
631631
| `searchable` | `boolean` | `false` | Include in search index |
632632
| `sortable` | `boolean` | `true` | Allow sorting by this field |
633633
| `hidden` | `boolean` | `false` | Hide from default views |
634-
| `readonly` | `boolean` | `false` | Prevent user edits |
634+
| `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) |
635635
| `externalId` | `boolean` | `false` | External system identifier |
636636
| `defaultValue` | `any` | — | Default value for new records |
637637
| `group` | `string` | — | Field grouping / section |

content/docs/data-modeling/formulas.mdx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,21 +36,25 @@ Every expression in metadata is persisted as the same envelope:
3636

3737
```ts
3838
type Expression = {
39-
dialect: 'cel' | 'js' | 'cron' | 'template';
39+
dialect: 'cel' | 'cron' | 'template';
4040
source?: string; // surface syntax
4141
ast?: unknown; // parsed AST (filled by `objectstack compile`)
4242
meta?: { rationale?: string; generatedBy?: string };
4343
};
4444
```
4545

46-
ObjectStack ships **four** registered dialects (M9.9):
46+
ObjectStack ships **three** registered expression dialects:
4747

4848
| Dialect | Use for | Helper |
4949
|:-----------|:-----------------------------------------------------|:--------------|
5050
| `cel` | computed values, predicates, defaults, formulas | `` cel`...` `` / `` F`...` `` / `` P`...` `` |
5151
| `cron` | recurring schedules (Job, ETL, sync, exports, jobs) | `` cron`...` `` |
5252
| `template` | text interpolation (`{{path}}`) — notif/prompt/title | `` tmpl`...` `` |
53-
| `js` | sandboxed L2 hook bodies (TypeScript source) | n/a |
53+
54+
The `js` **expression** dialect was retired in v16 (#3278, ADR-0058 addendum) —
55+
it was a stub with no engine. Procedural JavaScript is unaffected: it remains the
56+
**L2** authoring surface as a sandboxed `ScriptBody { language: 'js' }` in
57+
hook/action bodies (a separate enum), not an expression dialect.
5458

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

180185
Add new helpers in
@@ -211,6 +216,7 @@ come at two severities: **errors** fail the build; **warnings** are advisory
211216
| **Field must exist** — a typo'd `record.<field>` is flagged with a did-you-mean. | `record.amont``record.amount` | error |
212217
| **Unknown function** — a misspelled or nonexistent stdlib call. | `isBlnk(record.x)` | error |
213218
| **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 |
219+
| **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) |
214220

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

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

390408
```typescript

content/docs/kernel/events.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Every data hook is a single-argument handler `(ctx: HookContext) => void | Promi
7474
| `ctx.input` | **Mutable** input. Shapes: insert `{ data }`, update `{ id, data }`, delete `{ id }`. Modify this to change the operation. |
7575
| `ctx.result` | Operation result, available in `after*` events (mutable). |
7676
| `ctx.previous` | Record state before the operation (update/delete). |
77-
| `ctx.session` | Auth/tenancy info (`userId`, `tenantId`, `roles`, …). |
77+
| `ctx.session` | Auth/tenancy info (`userId`, `organizationId`, `roles`, …). |
7878
| `ctx.api` | Scoped cross-object data access. |
7979

8080
### Registering Data Hooks

content/docs/protocol/kernel/runtime-capabilities.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ The **Data Layer** capabilities define what query operations, data types, and bu
106106
|-----------|---------|-------------|
107107
| `transactions` | `true` | Database transaction support |
108108
| `bulkOperations` | `true` | Bulk create/update/delete |
109+
| `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`. |
109110

110111
### Driver Support
111112

content/docs/ui/translations.mdx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export default defineStack({
6969
| Picklist option labels | `objects.<name>.fields.<field>.options.<value>` |
7070
| View titles, descriptions, empty states | `objects.<name>._views.<view>` |
7171
| Action labels, confirm text, success messages | `objects.<name>._actions.<action>` |
72+
| Action result dialogs (title / description / acknowledge / field labels) | `objects.<name>._actions.<action>.resultDialog` |
7273
| Form sections | `objects.<name>._sections.<section>` |
7374
| App navigation | `apps.<app>.navigation.<id>.label` |
7475
| Dashboards and widgets | `dashboards.<name>` |
@@ -77,6 +78,20 @@ export default defineStack({
7778
The metadata types resolved per request are **object, view, action, app, and
7879
dashboard** — a field's labels are translated as part of its object document.
7980

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

8297
Per request, in this order:

examples/app-showcase/src/automation/flows/index.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1430,9 +1430,115 @@ export const ExpenseSignoffFlow = defineFlow({
14301430
],
14311431
});
14321432

1433+
/**
1434+
* Committee Quorum (#3266) — a `quorum` (M-of-N) approval, the collective
1435+
* sign-off complement to {@link ExpenseSignoffFlow}'s per-group 会签. A
1436+
* high-value expense report needs **any 2 of 3** committee members (manager /
1437+
* finance / legal) to approve; a single rejection still vetoes, and
1438+
* `minApprovals` clamps to the approver count so a misconfiguration can never
1439+
* deadlock. Where `per_group` requires one from EACH group, `quorum` counts a
1440+
* flat tally across all approvers.
1441+
*/
1442+
export const CommitteeQuorumFlow = defineFlow({
1443+
name: 'showcase_committee_quorum',
1444+
label: 'High-Value Expense — Committee Quorum',
1445+
description: 'Any 2-of-3 committee members approve a high-value expense report (#3266 quorum / M-of-N).',
1446+
type: 'autolaunched',
1447+
status: 'active',
1448+
nodes: [
1449+
{
1450+
id: 'start',
1451+
type: 'start',
1452+
label: 'On High-Value Submitted',
1453+
config: {
1454+
objectName: 'showcase_expense_report',
1455+
triggerType: 'record-after-update',
1456+
condition: 'status == "submitted" && previous.status != "submitted" && total_amount >= 5000',
1457+
},
1458+
},
1459+
{
1460+
id: 'committee',
1461+
type: 'approval',
1462+
label: 'Committee Sign-off (2 of 3)',
1463+
config: {
1464+
approvers: [
1465+
{ type: 'position', value: 'manager' },
1466+
{ type: 'position', value: 'finance' },
1467+
{ type: 'position', value: 'legal' },
1468+
],
1469+
behavior: 'quorum',
1470+
minApprovals: 2,
1471+
lockRecord: true,
1472+
},
1473+
},
1474+
{ id: 'approved', type: 'end', label: 'Approved' },
1475+
{ id: 'rejected', type: 'end', label: 'Rejected' },
1476+
],
1477+
edges: [
1478+
{ id: 'e1', source: 'start', target: 'committee' },
1479+
{ id: 'e2', source: 'committee', target: 'approved', label: 'approve' },
1480+
{ id: 'e3', source: 'committee', target: 'rejected', label: 'reject' },
1481+
],
1482+
});
1483+
1484+
/**
1485+
* Task Due Reminder (#1874) — a declarative `timeRelative` sweep, far more
1486+
* robust than a `record_change` flow gated on `due_date == daysFromNow(n)`
1487+
* (which fires only if the task happens to be edited on the exact threshold
1488+
* day). A daily sweep launches this flow **once per matching task** at T-minus
1489+
* 3 and 1 days before its `due_date`, with the task on the flow context. Swap
1490+
* `offsetDays` for `withinDays: 7` to nudge everything due within a week
1491+
* (negative = overdue lookback).
1492+
*/
1493+
export const TaskDueReminderFlow = defineFlow({
1494+
name: 'showcase_task_due_reminder',
1495+
label: 'Task Due Reminder',
1496+
description: 'Daily sweep: remind the owner 3 and 1 days before an open task is due (#1874 time-relative).',
1497+
type: 'schedule',
1498+
status: 'active',
1499+
runAs: 'system', // a sweep has no trigger user — elevate explicitly
1500+
nodes: [
1501+
{
1502+
id: 'start',
1503+
type: 'start',
1504+
label: 'Daily Sweep',
1505+
config: {
1506+
timeRelative: {
1507+
object: 'showcase_task',
1508+
dateField: 'due_date',
1509+
offsetDays: [3, 1], // — or — withinDays: 7 (negative = overdue lookback)
1510+
filter: { status: { $ne: 'done' } }, // optional, ANDed with the date window
1511+
},
1512+
// schedule defaults to daily 08:00 UTC; override with
1513+
// schedule: { type: 'cron', expression: '0 8 * * *' }
1514+
},
1515+
},
1516+
{
1517+
id: 'remind_owner',
1518+
type: 'notify',
1519+
label: 'Remind Owner',
1520+
config: {
1521+
topic: 'task.due_soon',
1522+
channels: ['inbox'],
1523+
severity: 'warning',
1524+
title: 'Task due soon: {record.title}',
1525+
message: 'Your task "{record.title}" is due on {record.due_date}.',
1526+
actionUrl: '/showcase_task',
1527+
},
1528+
},
1529+
{ id: 'end', type: 'end', label: 'End' },
1530+
],
1531+
edges: [
1532+
{ id: 'e1', source: 'start', target: 'remind_owner' },
1533+
{ id: 'e2', source: 'remind_owner', target: 'end' },
1534+
],
1535+
});
1536+
14331537
export const allFlows = [
14341538
TaskCompletedFlow,
14351539
ExpenseSignoffFlow,
1540+
CommitteeQuorumFlow,
1541+
TaskDueReminderFlow,
14361542
ReassignWizardFlow,
14371543
InquiryPurgeFlow,
14381544
BudgetApprovalFlow,

examples/app-showcase/src/data/objects/project.object.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,12 @@ export const Project = ObjectSchema.create({
124124
label: 'Project Status Flow',
125125
description: 'Projects progress through valid status transitions.',
126126
field: 'status',
127-
// State machines validate *transitions*, so they run on update only —
128-
// an insert sets the initial state (constrained by the select options).
129-
events: ['update'] as const,
127+
// `transitions` govern UPDATE; `initialStates` (#3165) is the FSM entry
128+
// point on INSERT — without it a `select` would accept ANY option as the
129+
// initial value, so a project could be born already `completed`. Requiring
130+
// `insert` in `events` is what makes the initialStates check run on create.
131+
events: ['insert', 'update'] as const,
132+
initialStates: ['planned'],
130133
message: 'Invalid project status transition.',
131134
transitions: {
132135
planned: ['active', 'cancelled'],

skills/objectstack-data/references/data-hooks.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -489,9 +489,9 @@ interface HookContext {
489489
// Execution context
490490
session?: {
491491
userId?: string;
492-
organizationId?: string; // Active org — blessed name. Matches the
493-
// `organization_id` column + `current_user.organizationId` (RLS)
494-
tenantId?: string; // @deprecated alias of organizationId (identical value)
492+
organizationId?: string; // Active org — the single blessed name. Matches the
493+
// `organization_id` column + `current_user.organizationId` (RLS).
494+
// The former `tenantId` alias was removed in #3290.
495495
roles?: string[];
496496
accessToken?: string;
497497
};
@@ -524,7 +524,7 @@ predicates, and in seed rows. Read it as **`organizationId`**:
524524
const org = ctx.user?.organizationId ?? ctx.session?.organizationId;
525525
```
526526

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

0 commit comments

Comments
 (0)