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

vercelBot commented Jul 27, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specBuildingBuildingPreview, CommentJul 27, 2026 2:52am

Request Review

@github-actionsgithub-actionsBot 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant

@os-zhuang