You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Three declarative capabilities that complete dynamic approver routing:
- `expression` approvers: CEL resolved at node entry over a CLOSED root set —
current.* (live record), trigger.* (submit snapshot), vars.* (flow
variables). `record`/bare fields are rejected before evaluation with
errors that prescribe the correct spelling (the runtime env would resolve
them as dyn → null → a silently-empty slate). resolveAs re-expands ids
through the existing graph lookups; per_group keys each intermediate value
as its own sign-off group. Missing key = loud error; only present-but-empty
counts as an empty slate.
- onEmptyApprovers: admin_rescue (default, = #3424) | fail | auto_approve.
Auto-approve rides NodeExecutionResult.branchLabel, which existed but was
never consumed on the synchronous completion path — the engine now honours
it (unlabelled traversal would walk approve AND reject).
- decisionOutputs: author-declared keys a decision may carry; accepted
outputs resume as <nodeId>.<key> variables, so a later node's expression
reads vars.<nodeId>.picked_departments — "previous approver picks the next
step's approvers" with no record-field detour. Undeclared keys reject;
decision/requestId reserved. Multi-approver tallies now always pin to the
open-time snapshot (unanimous previously re-resolved per decision, which
cannot work for expressions and could drift for live fields).
collectCelRootIdentifiers is exported from @objectstack/formula and shared by
the lint rules and the runtime pre-check so they can never drift. Resolution
inputs are audited as __resolvedFrom on the request snapshot. Three new lint
rules; SKILL.md and the approvals guide document the time-word contract
(current/trigger/vars vs condition record/previous vs hook ctx).
Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Dynamic approver routing for approval nodes (#3447 P2) — three new declarative capabilities:
11
+
12
+
**`expression` approvers.** A new approver type whose CEL expression resolves WHO approves at node entry, over exactly three roots: `current.*` (the record's live state), `trigger.*` (the submit-time snapshot) and `vars.*` (flow variables, incl. upstream node outputs). `record` and bare field names are rejected before evaluation — on this platform `record` always means "the record at event time", which is ambiguous at an approval node — with error messages that prescribe the correct spelling. The optional `resolveAs: 'user' | 'department' | 'position' | 'team'` re-expands each resolved id through the same graph lookups the static types use; with `behavior: 'per_group'` each intermediate value (e.g. each returned department) forms its own sign-off group. A missing key fails the node loudly; only a present-but-empty result counts as an empty slate.
13
+
14
+
**`onEmptyApprovers` policy.** What an empty resolved slate does, node-level, for all approver types: `admin_rescue` (default — request opens for privileged takeover, the #3424 behaviour), `fail` (node fails), or `auto_approve` (skip the request, continue down the `approve` edge with `output.autoApproved = true`). To support auto-approve, the automation engine now honours `NodeExecutionResult.branchLabel` on the synchronous completion path — the field existed but was only ever consumed via resume signals.
15
+
16
+
**Decision outputs.**`decide(..., { outputs })` hands structured data from the approver to the flow: the author declares allowed keys on the node (`decisionOutputs`), approvers fill values only, and accepted outputs resume the run as `<nodeId>.<key>` variables — a later approval node's expression can read `vars.<nodeId>.picked_departments`, closing "the previous approver picks the next step's approvers" without a record-field detour. Undeclared keys reject the decision; `decision`/`requestId` are reserved. Multi-approver tallies now always pin to the open-time approver snapshot (previously unanimous re-resolved at each decision against the payload snapshot).
17
+
18
+
Also: `collectCelRootIdentifiers` is exported from `@objectstack/formula` (shared by the new `os lint` rules and the runtime pre-check, so they can never drift), resolution inputs are audited on the request snapshot as `__resolvedFrom`, and three new lint rules gate expressions, empty-slate policies and reserved output keys at author time.
Approver entries resolve by kind — `position`, `user`, `field`, `manager`,
168
-
`team`, `department`, `queue`, and `org_membership_level`, described in the
169
-
[callout above](#3-the-approval-node), which is the one that silently resolves
170
-
to nobody when it's mistaken for a business hierarchy. An entry that resolves
210
+
`team`, `department`, `queue`, `org_membership_level`, and `expression`
211
+
(described in the [callout above](#3-the-approval-node) and in
212
+
[Dynamic approvers](#dynamic-approvers-3447)); `org_membership_level` is the
213
+
one that silently resolves to nobody when it's mistaken for a business
214
+
hierarchy. `field`, `manager`, and `expression` resolve against the record's
215
+
**live** state at node entry (#3447). An entry that resolves
171
216
to nobody is not an error: the request opens with an empty `pending_approvers`
172
217
and nothing can move it, so the run parks forever.
173
218
@@ -246,7 +291,7 @@ continuity levers on the same request. All are `POST
246
291
247
292
| Verb | Route | Who | Effect |
248
293
|:---|:---|:---|:---|
249
-
|`approve` / `reject`|`/approve``/reject`| pending approver | Records the decision (finalizes per `behavior`). Accepts `comment`, `attachments`. |
294
+
|`approve` / `reject`|`/approve``/reject`| pending approver | Records the decision (finalizes per `behavior`). Accepts `comment`, `attachments`, and — when the node declares `decisionOutputs` — structured `outputs` handed to the flow as `<nodeId>.<key>` variables. |
250
295
|`reassign`|`/reassign`| pending approver | Hands one slot to another user (`to`); the request stays pending. |
251
296
|`revise` (send back) |`/revise`| pending approver | Ends this round as **`returned`**, unlocks the record for rework (ADR-0044). |
252
297
|`request-info`|`/request-info`| pending approver | Asks the submitter for more info; the request **stays pending**. `comment` required. |
|**value**|`string`| optional | User id / membership tier / position / team / department / field / queue — per `type`; for `expression`, a CEL expression over `current.*` / `trigger.*` / `vars.*`|
59
+
|**resolveAs**|`Enum<'user' \| 'department' \| 'position' \| 'team'>`| optional | How an `expression` result is expanded into approvers (default 'user') |
59
60
|**group**|`string`| optional | Group label for per_group sign-off (e.g. "legal", "finance") |
60
61
61
62
@@ -67,11 +68,13 @@ const result = ApprovalDecision.parse(data);
0 commit comments