feat(approvals): expression approvers, empty-slate policy, decision outputs (#3447 P2) - #3532
Merged
Merged
Conversation
…utputs (#3447 P2) 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: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 6 package(s): 107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Jul 27, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
P2 of #3447 (design finalized in this comment after maintainer review): three declarative capabilities that complete dynamic approver routing on approval nodes. P1 (#3495, merged) made
field/managerapprovers read live data; this adds the general mechanism.1.
expressionapprovers — resolve WHO at node entry, by CELcurrent.*(live record at node entry),trigger.*(submit-time snapshot),vars.*(flow variables incl. node outputs). Backed by industry naming: ServiceNowcurrent, ServiceNow FDtrigger.record/ Power AutomatetriggerBody(), BPMN process variables.record/ bare fields rejected BEFORE evaluation. Everywhere elserecordmeans "the record at event time" (conditions: trigger snapshot; hooks: write payload) — ambiguous at an approval node, so the expression must say which time. The CEL env resolves unknown roots asdyn→null, which would produce a silently-empty slate; the AST pre-check fails the node loudly instead, with errors that prescribecurrent.<field>/trigger.<field>— written for the AI author fixing the flow on the next validate pass.resolveAs: 'user'(default)|'department'|'position'|'team're-expands each resolved id through the existing graph lookups. Withbehavior: 'per_group', each intermediate value (each returned department) forms its own sign-off group — dynamic co-sign (会签) in one node.vars.never_writtenthrows); only a present-but-empty value is an empty slate. Guard optional inputs withhas(...).2.
onEmptyApprovers— what an empty slate means (all approver types)admin_rescue(default — request opens for privileged takeover; exactly the #3424 behaviour, zero change for existing flows) |fail(node fails: config bug) |auto_approve(skip the request, continue downapprovewithoutput.autoApproved = true; opt-in because it waves the record through — the safe default is the only option that neither waves through nor kills the run).Auto-approve is powered by honouring
NodeExecutionResult.branchLabelon the synchronous completion path — the field existed ("Used by decision nodes") but was only ever consumed via resume signals; unlabelled traversal would walkapproveANDreject.3. Decision outputs — the previous approver picks the next step's approvers
Screen-node trust model: author declares keys, approvers only fill values. Undeclared keys reject the decision atomically (before any write);
decision/requestIdreserved; outputs land as<nodeId>.<key>(never bare variables, so an approver can't shadow author variables). Engine unchanged —ResumeSignal.outputalready existed. Co-sign votes accumulate onto the snapshot; the finalizing decision hands the merged set to the flow.Load-bearing fix surfaced by the integration test
Unanimous tallies used to re-resolve approvers at every decision against the payload snapshot (back-compat path predating the group snapshot). That can't work for expressions (decide time has no flow variables) and could drift for live fields (open-time slate ≠ decide-time slate). Open now snapshots
__approverGroupsfor every multi-approver behavior; decide always tallies against the open-time slate. Re-resolution survives only for pre-snapshot legacy rows.Authoring safety net (AI-first)
os lintrules:approval-expression-invalid(error: parse failure / illegal root, same helper as the runtime so they can never drift),approval-expression-no-empty-policy(info nudge),approval-decision-outputs-reserved(error).collectCelRootIdentifiersexported from@objectstack/formula— single implementation consumed by lint + runtime pre-check.__resolvedFromaudits the resolution INPUT (live field value / expression intermediates) next to the already-persisted result, so "why these people" stays answerable.objectstack-automation) +content/docs/automation/approvals.mdx: new Dynamic approvers section, result contract, the missing-vs-empty distinction, end-to-end example, and a time-word cheat sheet across surfaces (flowrecord/previousvs hookctx.record/ctx.previousvs approvalcurrent/trigger/vars).Tests
__resolvedFrom, missing-key loudness, 3 empty policies, 5 decision-output cases, and 4 end-to-end integration tests on a real AutomationEngine — including the issue's headline two-stage "lead picks co-signers" flow and the auto-approve branch wiring).collectCelRootIdentifiers), lint 22 (8 new), service-automation 360 (zero regressions on the branchLabel wiring), rest 372.gen:schema/gen:docs/gen:openapi/gen:api-surface/gen:skill-refs/gen:skill-docs); all six packages build with DTS.Not in this PR
P3 (programmatic resolver hook — Camunda TaskListener analog, routed to the hook surface per ADR-0077) and the objectui follow-ups (expression editor in Studio, decision-output form on the approval card — the card renders from
decisionOutputsdeclarations) tracked on #3447.Closes #3447.
🤖 Generated with Claude Code