diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index 84b9196204..47eab51636 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -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=`, 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`, diff --git a/content/docs/automation/hooks.mdx b/content/docs/automation/hooks.mdx index 2e57492aad..a59f813e0b 100644 --- a/content/docs/automation/hooks.mdx +++ b/content/docs/automation/hooks.mdx @@ -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 diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 5685575359..987c5dc358 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -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 | diff --git a/content/docs/data-modeling/formulas.mdx b/content/docs/data-modeling/formulas.mdx index c150b12cc8..45749e8c5f 100644 --- a/content/docs/data-modeling/formulas.mdx +++ b/content/docs/data-modeling/formulas.mdx @@ -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 @@ -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 @@ -211,6 +216,7 @@ come at two severities: **errors** fail the build; **warnings** are advisory | **Field must exist** — a typo'd `record.` 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 @@ -385,6 +391,18 @@ Field.formula({ }) ``` + +**`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. + + ### Financial Calculations ```typescript diff --git a/content/docs/kernel/events.mdx b/content/docs/kernel/events.mdx index 56e9d5e214..3b928ac812 100644 --- a/content/docs/kernel/events.mdx +++ b/content/docs/kernel/events.mdx @@ -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 diff --git a/content/docs/protocol/kernel/runtime-capabilities.mdx b/content/docs/protocol/kernel/runtime-capabilities.mdx index f6b577d1c6..317d05d625 100644 --- a/content/docs/protocol/kernel/runtime-capabilities.mdx +++ b/content/docs/protocol/kernel/runtime-capabilities.mdx @@ -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 diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 92bd5c85d8..33468b3a9e 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -69,6 +69,7 @@ export default defineStack({ | Picklist option labels | `objects..fields..options.` | | View titles, descriptions, empty states | `objects.._views.` | | Action labels, confirm text, success messages | `objects.._actions.` | +| Action result dialogs (title / description / acknowledge / field labels) | `objects.._actions..resultDialog` | | Form sections | `objects.._sections.
` | | App navigation | `apps..navigation..label` | | Dashboards and widgets | `dashboards.` | @@ -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. + +**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..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). + + ## How a locale is chosen Per request, in this order: diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index e28eddf2bf..3da954c266 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -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, diff --git a/examples/app-showcase/src/data/objects/project.object.ts b/examples/app-showcase/src/data/objects/project.object.ts index b1cf6e3078..323472639c 100644 --- a/examples/app-showcase/src/data/objects/project.object.ts +++ b/examples/app-showcase/src/data/objects/project.object.ts @@ -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'], diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index 104703ded1..2aeeb83df0 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -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; }; @@ -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.)