Skip to content

feat(approvals): decisionOutputs may be declared required (objectui#2955) - #3931

Merged
os-zhuang merged 1 commit into
mainfrom
fix/2955-decision-output-required
Jul 29, 2026
Merged

feat(approvals): decisionOutputs may be declared required (objectui#2955)#3931
os-zhuang merged 1 commit into
mainfrom
fix/2955-decision-output-required

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Closes the platform half of objectstack-ai/objectui#2955 (no separate issue here).

Why

decisionOutputs exists so an approver's decision can route the next step —
approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }] — but nothing made the approver actually answer. A skipped output resumed the run with the key absent, and the next node either faulted with EXPRESSION_FAILED or resolved an empty slate and stalled on onEmptyApprovers: 'admin_rescue' — long after the one person who could have filled it in had moved on. onEmptyApprovers is a recovery mechanism, not a contract.

What

A typed decisionOutputs entry may now carry required: true. Unlike type / multiple, which only shape the input widget, this one is enforced by the runtime.

  • @objectstack/specDecisionOutputDefSchema gains required; normalizeDecisionOutputs carries it through, so it reaches clients on decision_output_defs and a decision UI can block locally instead of round-tripping to a 400.
  • @objectstack/plugin-approvalsdecide() rejects an approve with no value, or a blank one ('', whitespace, [], an array of blanks), with VALIDATION_FAILEDbefore any write: neither the audit row nor the request moves.

Semantics (deliberate, documented)

  • Approve only. A reject leaves down the reject edge where nothing reads the outputs — demanding routing data to say "no" would trap the rejection. Outputs still ride a reject when the approver filled them in.
  • No elevation bypass. A one-click email action link and an auto_approve SLA escalation both fail rather than advance into a node that would resolve nobody. The escalation sweep already isolates a throwing request, so that decision stays pending and visibly overdue instead of silently breaking the run downstream.
  • Per decision. On a unanimous / quorum node every approver supplies the required outputs, and the finalizing decision's values are what the flow resumes with.

Docs: the generated reference table, content/docs/automation/approvals.mdx and skills/objectstack-automation/SKILL.md. The showcase's dynamic-approval flow now declares the flag — the honest shape for that demo, whose co-sign node is onEmptyApprovers: 'fail'.

Tests

8 new service tests + 4 spec normalizer tests. Full suites for every touched package: spec 6827, plugin-approvals 326, lint 540, rest 419 — all green.

Verified on the showcase runtime

CheckResult
POST …/approve with no outputs400 VALIDATION_FAILED, request still pending, audit table holds only the submit row
POST …/approve with { next_reviewers: [] }same 400
filled approvegoes through; the co-sign node's expression approver resolves to the picked user

The console side is objectstack-ai/objectui#2955 (marks the field required so the approver is stopped at the empty field rather than by this 400).

🤖 Generated with Claude Code

@vercel

vercelBot commented Jul 29, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredJul 29, 2026 10:37am

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Jul 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-approvals, @objectstack/spec.

105 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/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/spec)
  • content/docs/api/index.mdx(via @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 @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/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/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/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • 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/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/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-approvals, @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 @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/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v16.mdx(via @objectstack/spec)
  • content/docs/releases/v17.mdx(via @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.

…955)
`decisionOutputs` exists so an approver's decision can route the next step —
`approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }]`
— but nothing made the approver actually answer. A skipped output resumed the
run with the key absent, and the next node either faulted with
EXPRESSION_FAILED or resolved an empty slate and stalled on
`onEmptyApprovers: 'admin_rescue'`, long after the one person who could have
filled it in had moved on. `onEmptyApprovers` is a recovery mechanism, not a
contract.
A typed entry may now carry `required: true`. Unlike `type`/`multiple`, which
only shape the input widget, this one is enforced by the runtime: an approve
with no value — or a blank one ('', whitespace, [], an array of blanks) — is
rejected with VALIDATION_FAILED before any write, so neither the audit row nor
the request moves and the run cannot resume past the node with the key missing.
Reject never requires them: the run leaves down the reject edge where nothing
reads the outputs, and demanding routing data to say "no" would trap the
rejection. No elevation bypass either — a one-click email action link and an
`auto_approve` SLA escalation both fail rather than advance into a node that
would resolve nobody (the escalation sweep already isolates a throwing
request, so it stays pending and visibly overdue). Enforcement is per
decision, so on a unanimous/quorum node every approver supplies them and the
finalizing decision's values are what the flow resumes with.
`required` rides `normalizeDecisionOutputs`, so it reaches clients on
`decision_output_defs` and a decision UI can block locally instead of
round-tripping to a 400 — the console side is objectui#2955. The showcase's
dynamic-approval flow now declares it, which is the honest shape for that
demo: its co-sign node declares `onEmptyApprovers: 'fail'`.
Verified against the showcase runtime: an approve with no outputs and one with
`[]` both return 400 with the request still pending and no audit row, and a
filled approve routes co-sign to the picked user.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@baozhoutao@os-zhuang