Skip to content

Commit 8e6ab05

Browse files
docs(spec): document typed approval decision outputs as enforced at the decision, not a widget hint (#29371)
domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9SVUDueboMTukTWyoTJh412)_ Fixes #29340 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:审批节点「决策输出」的 spec 注释(三处)和审批文档一段,改成「`type`/`multiple` 在决策时强制校验」,删掉「只影响输入控件」的旧说法。 - 为什么改:#28658 裁决 A 要求运行时按声明校验决策输出;旧文字与 ADR-0104 2026-10-30 修订矛盾,且服务端 PR(席 5)要等这条文字先落地。 - 风险与回滚:只改注释和文档,无 schema、键、describe、导出变化;回滚即 revert 本 PR。 - 先看:`packages/spec/src/automation/approval.zod.ts`(DecisionOutputDefSchema 注释块)、`content/docs/automation/approvals.mdx`(typed declaration 段落)。 - 两分钟验证:`git grep -n "only shape" packages/spec/src/automation/approval.zod.ts content/docs/automation/approvals.mdx` 应无输出。 - 需要你做的:无,席位落地。 ## What changes - `packages/spec/src/automation/approval.zod.ts`, three docblocks (no code, no `.describe()` string): - `DecisionOutputDefSchema`: replaces "`type` and `multiple` only shape the INPUT WIDGET, never the accepted value" with the enforced contract: a picker output is judged by record id (`user`, `department`, `team`) or by machine name (`position`), through the lookup the matching `expression` approver's `resolveAs` uses; `multiple: true` takes a list with no entry twice, absent/false takes one value; a mismatch is refused with `400 VALIDATION_FAILED` naming `outputs.KEY` before any write, the request staying pending; `multiple` binds every entry, text and bare keys included; text is judged by shape only. - `required`: "Unlike `type`/`multiple` — which only shape the input widget" becomes "`type`/`multiple` judge the SHAPE of a supplied value; this one judges PRESENCE". - `decisionOutputs` on the approval node config (a third copy the card did not name): "The type describes the INPUT WIDGET only — the runtime accepts whatever value shape arrives … verbatim" is rewritten to the same contract. - `content/docs/automation/approvals.mdx`: the typed-declaration paragraph drops "The type shapes only the input widget" and gains the enforced rule (a short list), and the `required` paragraph no longer contrasts against an unenforced `type`/`multiple`. - `content/docs/automation/approvals.mdx` `next_reviewers` example now declares `{ key: 'next_reviewers', multiple: true }` (the bare key fed a list, which #28658's server PR refuses). - `.changeset/29340-decision-output-enforced-docs.md`: `@objectstack/spec` patch (the docblocks ship in the published `src/**/*.zod.ts`). No `.describe()` string carried the contradiction (checked: the `type`, `multiple` and `decisionOutputs` describes only say what the approver answers with), so nothing regenerates. Forced test edits: none. Ordering: the runtime enforcement is #28658's `domain:server` PR (branch `claude/project-thread-rsqu0h`), which merges after this one; the text is written as the contract, in the present tense, as ADR-0104's amendment is. ## Acceptance notes - **Error code: `VALIDATION_FAILED`, not the card's `VALIDATION_ERROR`.** The approvals door answers `400 VALIDATION_FAILED` for a malformed decision (the guide's own error-code table; the existing `required` refusal throws `VALIDATION_FAILED:`), and the enforcement branch for #28658 (head `88c96ad6`) throws `VALIDATION_FAILED` naming each `outputs.KEY`. `VALIDATION_ERROR` is the action-params gate's code from ADR-0104 D2. The text names the code the door answers. - **Approve and reject alike.** The guide says the typed check runs on an approve and a reject alike, matching the enforcement branch ("each admitted output is judged … on an approve and on a reject alike"). If that PR changes its reject choice, this sentence moves with it. - `plugin-approvals/src/approval-service.ts` carries the same "only shape the input widget" comment beside the `required` check; that file is the server half's, so it is left to that PR. ## Verification Run on head `fc024e80` (round 2: two text-only commits over `821642ea`). - `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated`: `✓ All 14 generated artifacts are up to date`, lock VERDICT command-exit 0. - Offline changeset check of this body: `node scripts/check-changeset-no-major.mjs --base origin/main --event FILE` exit 0, `✓ LEVEL AXIS` (clause-② no, metadata-protocol none: a non-protocol change at patch). The card's `Clause-②: no (narrowing)` was also measured: the gate accepts it too (exit 0), but reads the arm as BREAKING, which a text-only diff is not, so this body declares bare `no` as the seat's claim does. - Derived gate list (`dispatch-gates --commands`, 96 commands): 96/96 exit 0 after a full `turbo run build --filter=./packages/**` (lock VERDICT command-exit 0); `dispatch-gates --ran`: `96 derived famil(ies) accounted for — 96 run, 0 NOT-MEASURED`. - `pnpm --filter @objectstack/spec typecheck && pnpm --filter @objectstack/spec test`: `Test Files 791 passed (791)`, `Tests 23391 passed | 1 todo`, lock VERDICT command-exit 0. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. (No name added.) - [x] 3. No formatter ran; `pnpm lint` passes (CI). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed (none stale). - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the maintainer's Chinese summary). --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmP8ELNFNfBHmk387wA)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3291213 commit 8e6ab05

3 files changed

Lines changed: 61 additions & 14 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): a typed approval decision output's `type` and `multiple` are documented as enforced at the decision, not as a widget hint
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The `DecisionOutputDefSchema` docblock, its `required` docblock and the `decisionOutputs` docblock on the approval node config said `type` and `multiple` only shape the decision dialog's input widget and that the runtime accepts whatever value shape arrives. They now state the contract (ADR-0104 as amended 2026-10-30): `multiple: true` takes a list with no entry twice, and an absent or false `multiple` takes one value, for every entry (`text` and bare-string entries included, judged by shape only); a picker output's values are also resolved, `user`, `department` and `team` by record id and `position` by the machine name of a position someone holds; a mismatch is refused with `400 VALIDATION_FAILED` naming `outputs.KEY`, before any write. The approvals guide (`content/docs/automation/approvals.mdx`) says the same, and its "previous approver picks the next step's approvers" example now declares `{ key: 'next_reviewers', multiple: true }`, since the list it sends is refused under a bare key. Comment and docs text only: no schema key, `.describe()` string, export or type changes.

‎content/docs/automation/approvals.mdx‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,10 @@ run is never killed — and the log says both that the fallback fired and that i
327327
found nobody.
328328

329329
The "previous approver picks the next step's approvers" loop needs no record
330-
field at all: declare `decisionOutputs: ['next_reviewers']` on node A, have the
331-
approver decide with `{ outputs: { next_reviewers: ['u2', 'u3'] } }`, and let
330+
field at all: declare `decisionOutputs: [{ key: 'next_reviewers', multiple: true }]`
331+
on node A (`multiple: true` because the answer is a list; a bare
332+
`'next_reviewers'` takes one value), have the approver decide with
333+
`{ outputs: { next_reviewers: ['u2', 'u3'] } }`, and let
332334
node B's approver be
333335
`{ type: 'expression', value: 'vars.<nodeA-id>.next_reviewers' }`. The author
334336
declares output keys, approvers only fill values; undeclared keys reject the
@@ -339,12 +341,31 @@ multiple: true }` — which renders a multi-select user picker in the decision
339341
dialog instead of free text (`department` / `team` render the matching
340342
system-object picker and commit record ids; `position` renders a picker over
341343
the position registry and commits position machine names; `multiple` collects
342-
an array of them). The type shapes only the input widget; the whitelist
343-
still works by `key`, so bare strings and typed declarations mix freely.
344+
an array of them). The whitelist still works by `key`, so bare strings and
345+
typed declarations mix freely.
346+
347+
The runtime enforces every declaration at the decision, on an approve and a
348+
reject alike, before anything is written:
349+
350+
- `multiple: true` takes a list, and only a list, with no entry twice. An
351+
absent or `false` `multiple` takes one value. This holds for every entry,
352+
`text` and bare strings included.
353+
- A picker output's values are also resolved, the way the matching
354+
`expression` approver's `resolveAs` expands them, in the request's
355+
organization: `user`, `department` and `team` by record id, `position` by
356+
the machine name of a position someone holds. A position name nobody holds is
357+
refused, and so is a `sys_position` record id: either would route to nobody.
358+
- A mismatch is refused with `400 VALIDATION_FAILED` naming every
359+
`outputs.<key>` that does not match. No decision is recorded and the request
360+
stays pending, so the approver can decide again.
361+
362+
A `text` output (the default, and every bare-string entry) is judged by shape
363+
only: its value is free text. A blank value is left to `required`, below.
344364

345365
Add **`required: true`** when the next step cannot run without it — the very
346366
case above, where node B has nobody to route to if the lead skipped the field.
347-
Unlike `type` / `multiple`, this one is enforced by the runtime: an **approve**
367+
Where `type` / `multiple` judge the shape of a value the approver supplies,
368+
`required` judges whether one is there: an **approve**
348369
carrying no value (or a blank one — `''`, `[]`) for a required key is rejected
349370
before any write, so the run can never resume past the node with the key
350371
missing. A **reject** never requires it: the run leaves down the `reject` edge,

‎packages/spec/src/automation/approval.zod.ts‎

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -656,8 +656,19 @@ export const DecisionOutputDefSchema = lazySchema(() => strictObject(
656656
* A TYPED decision-output declaration (#3447 P2 follow-up). The bare-string
657657
* form of a `decisionOutputs` entry renders as free text; this form tells the
658658
* decision UI which picker to render and whether to collect one value or many.
659-
* The runtime treats `key` as the whitelist entry either way — `type` and
660-
* `multiple` only shape the INPUT WIDGET, never the accepted value.
659+
* The runtime treats `key` as the whitelist entry either way, and it ENFORCES
660+
* `type` and `multiple` at the decision (ADR-0104 as amended 2026-10-30;
661+
* #28658 ruling A). `multiple: true` takes a list, with no entry twice;
662+
* `multiple` absent or false takes one value — for every entry, `text` and
663+
* bare-string entries included. A picker output's values are also resolved,
664+
* through the lookup the `expression` approver with the matching `resolveAs`
665+
* expands them with:
666+
* `user`, `department` and `team` by record id, `position` by the machine
667+
* name of a position someone in the request's organization holds (a name
668+
* nobody holds is refused). A `text` output is judged by shape only. A
669+
* decision whose output does not match is refused with
670+
* `400 VALIDATION_FAILED` naming `outputs.<key>`, before any write — no
671+
* decision is recorded and the request stays pending.
661672
*/
662673
{
663674
surface: 'this decision-output declaration',
@@ -696,8 +707,8 @@ export const DecisionOutputDefSchema = lazySchema(() => strictObject(
696707
/**
697708
* The approver must supply this output to APPROVE (objectui#2955).
698709
*
699-
* Unlike `type`/`multiple` — which only shape the input widget — this one
700-
* IS enforced by the runtime: an approve carrying no value for a required
710+
* `type`/`multiple` judge the SHAPE of a value the approver supplies; this
711+
* one judges PRESENCE: an approve carrying no value for a required
701712
* key is rejected before any write, so a downstream `expression` approver
702713
* reading `vars.<nodeId>.<key>` can never face a missing key. Without it an
703714
* author's only backstop was `onEmptyApprovers` — the run reaches the next
@@ -1201,16 +1212,21 @@ export const ApprovalNodeConfigSchema = lazySchema(() => strictObject(
12011212
* next step's approvers" without a record-field detour. A decision carrying
12021213
* undeclared keys is rejected; `decision` / `requestId` are reserved.
12031214
*
1204-
* An entry is either a bare key (a plain text input in the decision UI) or a
1205-
* TYPED declaration — `{ key, type, multiple }` — that tells the decision UI
1215+
* An entry is either a bare key (a plain text input in the decision UI,
1216+
* taking one value) or a TYPED declaration — `{ key, type, multiple }` — that
1217+
* tells the decision UI
12061218
* to render a picker instead of free text, sourced like the approver type of
12071219
* the same name ({@link DecisionOutputDefSchema}'s `type`): `user` /
12081220
* `department` / `team` → a record picker committing ids, `position` → the
12091221
* position registry, committing machine names. `multiple: true` collects an
12101222
* ARRAY of them (the natural fan-out shape for an `expression` approver
1211-
* downstream). The type describes the INPUT WIDGET only — the runtime accepts
1212-
* whatever value shape arrives (a single value, an array, CSV) and hands it
1213-
* to the flow verbatim.
1223+
* downstream). The runtime enforces the declaration at the decision:
1224+
* `multiple: true` takes a list with no entry twice and an absent or false
1225+
* `multiple` takes one value, for every entry (`text` and bare keys
1226+
* included, judged by shape only); a picker value must also name
1227+
* a record id (`user` / `department` / `team`) or the machine name of a
1228+
* position someone holds (`position`). A mismatch is refused with
1229+
* `400 VALIDATION_FAILED` naming `outputs.<key>` before any write.
12141230
*/
12151231
decisionOutputs: z.array(z.union([
12161232
z.string(),

0 commit comments

Comments
 (0)