Repository navigation
fix(plugin-approvals)!: judge typed decision outputs against their declared type and multiple - #29372
Conversation
…clared type and multiple An admitted decision output of type user, department, team or position is judged against its declaration before any write: multiple: true takes a list with no entry twice, otherwise one value, and each value must resolve through the lookup the matching expression approver's resolveAs uses. A mismatch is a VALIDATION_FAILED naming outputs.KEY; nothing is recorded and the request stays pending. Claude-Session: https://claude.ai/code/session_01ADHkmPp7x4FxopdKR7keh7 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmPp7x4FxopdKR7keh7 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmPp7x4FxopdKR7keh7 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 100d782f466c11b61086327f2f85b292858aa07f && git checkout 100d782f466c11b61086327f2f85b292858aa07f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dbca3ef034537269cef482b342aa4ba7169dfe42 f9f2b6f89205cc07b05c3e3a981ca419784b067a && git checkout -B drift-repro dbca3ef034537269cef482b342aa4ba7169dfe42 && git merge --no-ff f9f2b6f89205cc07b05c3e3a981ca419784b067a
node scripts/docs-audit/affected-docs.mjs --json dbca3ef034537269cef482b342aa4ba7169dfe42
|
…shape too multiple binds every declaration, text and bare keys included: a list with no entry twice when multiple: true, one value otherwise. Text values are not resolved. Claude-Session: https://claude.ai/code/session_01ADHkmPp7x4FxopdKR7keh7 Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 Contract-review-claim: session_01ADHkmQKwKFCp1m6Pb6Q8c1 · head 3eb0a96 · 2026-11-02T14:08Z The narrowing face is reviewed at |
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 Contract reviewServed-tier: Face: narrowing. The PR body and the changeset both carry ① Derived judgmentsEach accept-set change the diff implies, judged against the ruling and the code it cites.
② Semver level
③ Boundary flagsDev flags and
④ Design judgmentThe narrowing against the ruling and the published spec on
Findings:
Implemented-by: VERDICT: FAIL |
A text or untyped decision output declared `multiple: true` now renders as a tags param, so both decision surfaces (record header and Approval Center) send a trimmed, distinct string array instead of one comma string, which the decision door now refuses. A single-value output stays one text box whose comma is literal. The comma hint `actions.decisionOutput.helpMultiValue` leaves all ten locale packs. Pins: param shape through resolveActionParams/paramToField, the real ActionParamDialog collecting and folding a list, and the server accepting the Console-shaped body while refusing the old comma string. Changeset: CSV "Before", pending bare-key snapshots, ADR-0105 D9 note, remedy reworded. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmPp7x4FxopdKR7keh7
…-rsqu0h Brings in the merged spec text for typed decision outputs. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmPp7x4FxopdKR7keh7
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmPp7x4FxopdKR7keh7
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 Contract-review-claim: session_01ADHkmQKwKFCp1m6Pb6Q8c1 · head c7ca5d1 · 2026-11-02T19:26Z (round 3, final head) |
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 Contract reviewServed-tier: Face: narrowing, as before. ① Derived judgmentsThe prior record's ④ finding 1 and ② should-fixes, each against the lines that answer it; then the Console's accept-set faces.
② Semver level
③ Boundary flags
④ Design judgmentThe Console change against the narrowed door, cases tried:
Findings:
Implemented-by: Exit: (b) PASS with should-fix — (1) one sentence in |
…string Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmPp7x4FxopdKR7keh7
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 Contract reviewServed-tier: Findings:
Exit: (a) PASS — the one should-fix from record-r3 is closed by an accurate sentence; check-runs on this head were in progress (none failed) when read, and the seat confirms them green before landing. |
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 维护者速读
席位意见:与裁决 A 及席位答复 Q1–Q4 相符。第 2 轮合同复审提出的 Console 断裂(必改)由本 PR 的 Console 同步关闭;第 3 轮仅剩的一句 changeset 补充已由 f9f2b6f 落实,增量复审 PASS。 席位核对ACCEPT ·
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1
Requested by Steve · project thread
Fixes #28658
Clause-②: no (narrowing)
Metadata-protocol: none
Ruling-ref: 6566731522
Skills-docs: follow-up #29512 — skills/objectstack-automation/references/state-machines-and-approvals.md
维护者速读
type(user / department / team / position)和multiple时,审批人提交的值现在当场按声明校验:选人选部门选团队要能查到真实记录,岗位要是有人担任的岗位名;multiple: true必须给列表且不能重复,没声明multiple只能给单个值。不合格就 400 拒绝、点名outputs.键名,什么都不记录,请求保持待审。userdecision output is checked for presence only — an id that resolves to no user records the decision, then answers 500 RESUME_FAILED and strands the run #28658 裁决 A。以前填一个不存在的用户 id,审批照样记为通过,下一步表达式审批人解析不到人,流程 500 卡死(请求显示「已通过」,流程已失败)。multiple: true必须是不重复的列表,否则只能一个值),四种选人类型还要能查到记录。Console 同步改:声明了multiple: true的文本输出改成标签输入,发送字符串数组;单值文本仍发一个字符串,逗号原样保留。驳回时带了不合格输出也会被拒。回滚即撤掉本 PR(两处一起撤)。ui/packages/app-shell/src/utils/decisionOutputParams.ts(Console 发列表);packages/plugins/plugin-approvals/src/approval-service.ts的assertDecisionOutputsMatchDeclarations与decisionOutputValueRefusal(各类型复用表达式审批人的同一组查找);decideNode里required检查之后的调用点。pnpm --filter @objectstack/plugin-approvals exec vitest run src/approval-node.test.ts -t "typed user output",应看到 1 条通过:错 id、单值、重复都被拒且流程仍停在第一步,合法选择后两步走完。type/multipleonly shape the widget — rewrite to the enforced rule (#28658 ruling A, text slice) #29340 已由 docs(spec): document typed approval decision outputs as enforced at the decision, not a widget hint #29371 合入)。What changes
ApprovalService.decideNode,packages/plugins/plugin-approvals/src/approval-service.ts): after therequiredcheck and beforeassertRunResumableand any write, each admitted output is judged by the newassertDecisionOutputsMatchDeclarations. OneVALIDATION_FAILEDnames everyoutputs.KEYthat does not match; the approvals door already maps that prefix to400(handleApprovalErrorinpackages/rest/src/approvals-routes.ts, unchanged). Nothing is written: nosys_approval_actionrow, no__decisionOutputssnapshot, the request stayspendingand its slate unchanged.multiple: truetakes a list and only a list, with no entry twice;multipleabsent orfalsetakes one value; each value is non-blank text.expressionapprover'sresolveAsuses (decisionOutputValueRefusal), in the request's organization:user:reassignTargetRefusal(value, null), the predicate auser/expressionapprover asks (no organization, approvals: auserapprover naming a nonexistent user id opens a request on a phantom slot with no warning and no onEmptyApprovers fork, so the request is stuck pending forever #27397). A slot address (KIND:VALUE) or an email is refused as "not a user id": the user picker commits record ids.department:businessUnitIsLive, the seed check ofexpandBusinessUnitUsers, extracted into its own method (the expansion now calls it; behaviour unchanged).team:readTeamRow, thesys_teamread ofteamIsProvablyOutsideOrg, extracted (that screen now calls it; behaviour unchanged). Refused when no row exists, or the row names another organization.position:expandPositionUsers(name, org), the expansion theexpressionapprover uses, which matchessys_user_position.positionby machine name. Asys_positionrecord id has no holder under that name and is refused.ui/packages/app-shell/src/utils/decisionOutputParams.ts, the one place both decision surfaces build their decision-output params): atextor untyped output declaredmultiple: true(and apositionoutput with no registry reader) renders as atagsparam instead of one comma text box.TagsFieldstores astring[], trims each entry and skips a blank or repeated one, so the decide body carries a list the duplicate refusal never trips on. Both folds hand the value on untouched:foldDecisionOutputs(record header) and the runtime api handler'soutputs.KEYfold (packages/runtime/src/action-execution.ts, Approval Center), whose list pass-through is already pinned inapi-action-forward.test.ts. No fold changed. A single-value output stays atextbox; its comma is literal. Every output's help now readsactions.decisionOutput.help; the comma hintactions.decisionOutput.helpMultiValueleaves all ten locale packs (its only call site is gone).textand bare keys: judged on shape only (seat answer Q4 = B).multiple: truetakes a list with no entry twice;multipleabsent or false takes one value. A bare key (decisionOutputs: ['note']) therefore takes one value, and a list is what an output declaredmultiple: truecollects. Text values are not resolved. A blank value ('',[],null) isrequired's question only..changeset/28658-decision-output-type-multiple.md:patchfor@objectstack/plugin-approvals, the two declaration lines, the ADR-0087 dispositionnot-required (no-migration-prescription), and a BREAKING paragraph naming which decisions are now refused..changeset/28658-console-decision-output-list.md(@object-ui/app-shell,@object-ui/i18npatch, Clause-② no (narrowing), Metadata-protocol none, ADR-0087not-required (no-migration-prescription), BREAKING only for the deleted key inTranslationKeys). The plugin-approvals changeset now names the CSV string the spec on main admitted for a list output, pending bare-key snapshots, the ADR-0105 D9 organization note, and a remedy that prescribes no rewrite.The spec docblock and
content/docs/automation/approvals.mdxtext are #29340 (spec lane, landed as #29371); this PR touches nopackages/spec/**.Tests
approval-service.test.ts, new describetyped decision outputs are judged against their declaration (#28658)(22 tests; the formertext unchangedtest is replaced by four: text values free in the declared shape; textmultiple: truerefuses a scalar; text, declared or bare key, withoutmultiplerefuses a list; a text list refuses a repeated entry): an unknown user id refused namingoutputs.next_reviewerswith nothing recorded; slot address and email refused; scalar formultiple: true; list for absent and forfalsemultiple; a duplicated entry; a non-text entry; two bad outputs named in one refusal; a valid list recorded and handed on; position by machine name (held name passes, asys_positionid refused, a name held only in another organization refused); department (active passes; unknown, inactive, other organization refused); team (row passes; unknown and other organization refused); a reject with a bad output refused, one with none goes through; a mid-tally co-sign vote judged before it is counted;textunchanged; a blank optional picker output not judged.approval-node.test.ts, new test: the showcase dynamic-approval shape (typed requireduserlist at stage 1,expressionapprover withonEmptyApprovers: 'fail'at stage 2) through the real automation engine. A wrong id, a scalar and a duplicate are refused with stage 1 still pending, no decision row and the run still suspended; a valid pick opens stage 2 on the picked users and the run completes.approval-service.test.ts, two more tests in that describe: "the Console's decide body passes" (user list, text tags list, untyped tags list, bare key with a literal comma, all recorded and handed on) and "the comma string the Console used to send for a list output is refused".ui/packages/app-shell/src/utils/decisionOutputParams.test.ts, new describe "a free-value output is sent in its declared shape (objectstack#28658)" (6 tests: text, untyped and degraded-position lists reach the widget astags; a bare key andmultiple: falsestaytext; the fold keeps a list a list and a comma literal). Two help-text expectations there and one inDeclaredActionsBar.test.tsxmove fromhelpMultiValuetohelp(forced: the key is retired).ui/packages/app-shell/src/views/ActionParamDialog.decisionOutputList-28658.test.tsx: the real dialog, fed the synthesized params throughresolveActionParams, collects' alpha ','alpha', a blank and'beta'by Enter and'gamma'by blur, and resolves{ 'outputs.labels': ['alpha', 'beta', 'gamma'], 'outputs.note': 'a, b' }, folded to the same list.multiple: true):required outputs: a filled approve goes through…senttype: 'position'valuespos_1/pos_2that no one holds. It now seeds twosys_user_positionholders under those names.required outputs: every approver of a multi-approver node must supply themsenttype: 'user'valuesu7/u8, which are not accounts in that double. It now adds the two accounts.approval-service.test.ts"decision outputs: accepted keys return from decideNode and snapshot as __decisionOutputs":next_reviewersis declared{ key: 'next_reviewers', multiple: true }.approval-node.test.ts"decide outputs feed the NEXT approval node via vars.NODE.KEY (审批流无法在「运行中」动态确定审批人——审批人在提交时即被 $trigger 快照锁定(动态路由 / 动态会签受阻) #3447 P2)": the same change.Acceptance notes
400 VALIDATION_FAILED, the door's one validation code (the ruling'sVALIDATION_ERRORspelling was a slip); the spec text card spec(approvals): the typed decision-output docblock and approvals docs saytype/multipleonly shape the widget — rewrite to the enforced rule (#28658 ruling A, text slice) #29340 carries the same spelling. Q2 and Q3 = A, as delivered.expressionapprover that declaresorganization(ADR-0105 D9) resolves in its directory organization instead, which the decision cannot know. Raised inopen_questions.expressionapprover has is the holder expansion; nosys_positionregistry lookup exists on this path, and the card says not to write a second one.Verification
Final head
c7ca5d18f(a913892b7plus a merge oforigin/mainincluding spec #29371, plus changeset wording):dispatch-gates67/67 derived exit 0 (--ran: 67 run, 0 NOT-MEASURED); full dogfood 3277 passed / 0 failed; plugin-approvals 1404 passed with tsc green; ui app-shell 18804 passed; ui i18n 1443 passed; app-shell and i18n type-checks, eslint on the touched files, and the i18n keys, drift, dead-keys and eager-locale-catalogues checks exit 0. The older readings below are kept for the record: they were taken at head59bdcb56unless they say otherwise, and at round-2 head3eb0a962(seat answer Q4 = B) the derived gates read 63/63 exit 0, plugin-approvals 1402/1402 with typecheck green, and the full dogfood battery 3243 passed / 0 failed.Ablations: 16 legs, each red, each restored byte-identical
The eleven unit legs ran at
0b23046cand the end-to-end leg at665d0599;approval-service.tsis byte-identical at59bdcb56(the merge ofmaintouched noplugin-approvalsfile). Each leg mutatedapproval-service.tsthroughscripts/ablation-replace.mjs(anchor hit x1, restore proved by blob == HEAD and an emptygit diff HEAD), ran the typed-output block (vitest run src/approval-service.test.ts -t typed, 19 tests), and was restored. The tests import the service fromsrc, so no build leg applies.assertDecisionOutputsMatchDeclarationscall removedmultiple: truelist check offapproval-node.test.ts -t "typed user output"3eb0a962)3eb0a962)a913892b7)type: 'tags'→'text'indecisionOutputParams.tsexpected { 'outputs.labels': 'gamma', …(1) } to deeply equal { 'outputs.labels': [ …(3) ], …(1) }a913892b7)Error: expected a refusal)The end-to-end leg reproduces the card's defect exactly:
Tests and gates
pnpm --filter @objectstack/plugin-approvals test→ Test Files 107 passed (107), Tests 1399 passed (1399);typecheck(tsc, scripts tsconfig,check:test-typecheckOK: 8 files / 321 errors held in the ledger, unchanged) → lock VERDICT command-exit 0.Clause-②carries(narrowing)): closure buildturbo run build --filter='@objectstack/dogfood^...'thenpnpm --filter @objectstack/dogfood test→ Test Files 393 passed, 3 skipped (396); Tests 3243 passed, 28 skipped (3271); lock VERDICT command-exit 0.--no-inline-configoverpackages/plugins/plugin-approvals→ exit 0.check:nul-bytes→ exit 0.dispatch-gates --commandsderived 63 commands; all 63 recorded exit 0. Three first answered exit 3 (prerequisites, nothing measured) and were re-run green:check-plugin-teardown-shape --self-testafter fetching its positive-control commit,check:type-check-debtandcheck:dual-build-cjs-loadsafter the closure builds.dispatch-gates --ran→ "63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED".Generated by Claude Code