Skip to content

feat(approvals): expression approvers, empty-slate policy, decision outputs (#3447 P2) - #3532

Merged
os-zhuang merged 1 commit into
mainfrom
feat/3447-expression-approvers
Jul 27, 2026
Merged

feat(approvals): expression approvers, empty-slate policy, decision outputs (#3447 P2)#3532
os-zhuang merged 1 commit into
mainfrom
feat/3447-expression-approvers

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

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/manager approvers read live data; this adds the general mechanism.

1. expression approvers — resolve WHO at node entry, by CEL

{ type: 'expression', value: cel`current.co_review_departments`, resolveAs: 'department' }
  • Closed root set — exactly current.* (live record at node entry), trigger.* (submit-time snapshot), vars.* (flow variables incl. node outputs). Backed by industry naming: ServiceNow current, ServiceNow FD trigger.record / Power Automate triggerBody(), BPMN process variables.
  • record / bare fields rejected BEFORE evaluation. Everywhere else record means "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 as dynnull, which would produce a silently-empty slate; the AST pre-check fails the node loudly instead, with errors that prescribe current.<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. With behavior: 'per_group', each intermediate value (each returned department) forms its own sign-off group — dynamic co-sign (会签) in one node.
  • Missing key = loud error (vars.never_written throws); only a present-but-empty value is an empty slate. Guard optional inputs with has(...).

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 down approve with output.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.branchLabel on the synchronous completion path — the field existed ("Used by decision nodes") but was only ever consumed via resume signals; unlabelled traversal would walk approve AND reject.

3. Decision outputs — the previous approver picks the next step's approvers

// Node A declares keys; the approver fills values on decide:
{ id: 'lead_review', config: { approvers: [], decisionOutputs: ['next_reviewers'] } }
// POST …/approve { outputs: { next_reviewers: ['u2','u3'] } }
// Node B resolves them at entry:
{ id: 'co_sign', config: { approvers: [{ type: 'expression', value: cel`vars.lead_review.next_reviewers` }] } }

Screen-node trust model: author declares keys, approvers only fill values. Undeclared keys reject the decision atomically (before any write); decision/requestId reserved; outputs land as <nodeId>.<key> (never bare variables, so an approver can't shadow author variables). Engine unchanged — ResumeSignal.output already 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 __approverGroups for 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)

  • 3 new os lint rules: 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).
  • collectCelRootIdentifiers exported from @objectstack/formula — single implementation consumed by lint + runtime pre-check.
  • __resolvedFrom audits the resolution INPUT (live field value / expression intermediates) next to the already-persisted result, so "why these people" stays answerable.
  • SKILL.md (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 (flow record/previous vs hook ctx.record/ctx.previous vs approval current/trigger/vars).

Tests

  • plugin-approvals 191 (25 new: expression matrix incl. current/trigger/vars, array & CSV fan-out, per-user OOO, resolveAs + per_group sub-keys, unstaffed-target literal, __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).
  • formula 248 (6 new for collectCelRootIdentifiers), lint 22 (8 new), service-automation 360 (zero regressions on the branchLabel wiring), rest 372.
  • Red-pass verified: stashing the three runtime files (keeping spec/formula/lint + tests) turns exactly the 25 new behavior tests red, 142 existing stay green.
  • Generators re-run (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 decisionOutputs declarations) tracked on #3447.

Closes #3447.

🤖 Generated with Claude Code

…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>
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Building Building Preview, Comment Jul 27, 2026 2:52am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/xl labels Jul 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/formula, @objectstack/lint, @objectstack/plugin-approvals, @objectstack/rest, packages/services, @objectstack/spec.

107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/plugin-approvals, packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via packages/services, @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/formula, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/formula, @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/formula, @objectstack/plugin-approvals, @objectstack/rest, packages/services, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest, packages/services, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/formula, @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-approvals, @objectstack/rest, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v15.mdx (via @objectstack/formula)
  • content/docs/releases/v16.mdx (via @objectstack/formula, @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

审批流无法在「运行中」动态确定审批人——审批人在提交时即被 $trigger 快照锁定(动态路由 / 动态会签受阻)

1 participant