Skip to content

fix(plugin-approvals)!: judge typed decision outputs against their declared type and multiple - #29372

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-rsqu0h
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-rsqu0h

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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

维护者速读

What changes

  • The decision door (ApprovalService.decideNode, packages/plugins/plugin-approvals/src/approval-service.ts): after the required check and before assertRunResumable and any write, each admitted output is judged by the new assertDecisionOutputsMatchDeclarations. One VALIDATION_FAILED names every outputs.KEY that does not match; the approvals door already maps that prefix to 400 (handleApprovalError in packages/rest/src/approvals-routes.ts, unchanged). Nothing is written: no sys_approval_action row, no __decisionOutputs snapshot, the request stays pending and its slate unchanged.
    • multiple: true takes a list and only a list, with no entry twice; multiple absent or false takes one value; each value is non-blank text.
    • Each value resolves through the lookup the matching expression approver's resolveAs uses (decisionOutputValueRefusal), in the request's organization:
      • user: reassignTargetRefusal(value, null), the predicate a user / expression approver asks (no organization, approvals: a user approver 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 of expandBusinessUnitUsers, extracted into its own method (the expansion now calls it; behaviour unchanged).
      • team: readTeamRow, the sys_team read of teamIsProvablyOutsideOrg, 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 the expression approver uses, which matches sys_user_position.position by machine name. A sys_position record id has no holder under that name and is refused.
    • A directory read that fails proves nothing and refuses nothing, as every resolution in this file reads one.
  • The Console (ui/packages/app-shell/src/utils/decisionOutputParams.ts, the one place both decision surfaces build their decision-output params): a text or untyped output declared multiple: true (and a position output with no registry reader) renders as a tags param instead of one comma text box. TagsField stores a string[], 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's outputs.KEY fold (packages/runtime/src/action-execution.ts, Approval Center), whose list pass-through is already pinned in api-action-forward.test.ts. No fold changed. A single-value output stays a text box; its comma is literal. Every output's help now reads actions.decisionOutput.help; the comma hint actions.decisionOutput.helpMultiValue leaves all ten locale packs (its only call site is gone).
  • text and bare keys: judged on shape only (seat answer Q4 = B). multiple: true takes a list with no entry twice; multiple absent or false takes one value. A bare key (decisionOutputs: ['note']) therefore takes one value, and a list is what an output declared multiple: true collects. Text values are not resolved. A blank value ('', [], null) is required's question only.
  • Reject: a reject carrying a mismatched output is refused too. The outputs ride the reject's resume and snapshot like an approve's, and the ruling speaks of any admitted output. A reject carrying no outputs is unchanged.
  • Changeset .changeset/28658-decision-output-type-multiple.md: patch for @objectstack/plugin-approvals, the two declaration lines, the ADR-0087 disposition not-required (no-migration-prescription), and a BREAKING paragraph naming which decisions are now refused.
  • Changesets: add .changeset/28658-console-decision-output-list.md (@object-ui/app-shell, @object-ui/i18n patch, Clause-② no (narrowing), Metadata-protocol none, ADR-0087 not-required (no-migration-prescription), BREAKING only for the deleted key in TranslationKeys). 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.mdx text are #29340 (spec lane, landed as #29371); this PR touches no packages/spec/**.

Tests

  • approval-service.test.ts, new describe typed decision outputs are judged against their declaration (#28658) (22 tests; the former text unchanged test is replaced by four: text values free in the declared shape; text multiple: true refuses a scalar; text, declared or bare key, without multiple refuses a list; a text list refuses a repeated entry): an unknown user id refused naming outputs.next_reviewers with nothing recorded; slot address and email refused; scalar for multiple: true; list for absent and for false multiple; 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, a sys_position id 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; text unchanged; a blank optional picker output not judged.
  • approval-node.test.ts, new test: the showcase dynamic-approval shape (typed required user list at stage 1, expression approver with onEmptyApprovers: '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 as tags; a bare key and multiple: false stay text; the fold keeps a list a list and a comma literal). Two help-text expectations there and one in DeclaredActionsBar.test.tsx move from helpMultiValue to help (forced: the key is retired).
  • ui/packages/app-shell/src/views/ActionParamDialog.decisionOutputList-28658.test.tsx: the real dialog, fed the synthesized params through resolveActionParams, 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.
  • Forced test edits (the first two because the old values never named anything; the last two because a bare key that was sent a list now declares multiple: true):
    • required outputs: a filled approve goes through… sent type: 'position' values pos_1 / pos_2 that no one holds. It now seeds two sys_user_position holders under those names.
    • required outputs: every approver of a multi-approver node must supply them sent type: 'user' values u7 / 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_reviewers is 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

  • Refusal code. Seat answer Q1 = A: 400 VALIDATION_FAILED, the door's one validation code (the ruling's VALIDATION_ERROR spelling was a slip); the spec text card spec(approvals): the typed decision-output docblock and approvals docs say type/multiple only shape the widget — rewrite to the enforced rule (#28658 ruling A, text slice) #29340 carries the same spelling. Q2 and Q3 = A, as delivered.
  • Organization. The department, team and position checks resolve in the request's organization. A downstream expression approver that declares organization (ADR-0105 D9) resolves in its directory organization instead, which the decision cannot know. Raised in open_questions.
  • Position = held. A position name nobody in the organization holds is refused, since the only position resolver the expression approver has is the holder expansion; no sys_position registry lookup exists on this path, and the card says not to write a second one.

Verification

Final head c7ca5d18f (a913892b7 plus a merge of origin/main including spec #29371, plus changeset wording): dispatch-gates 67/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 head 59bdcb56 unless they say otherwise, and at round-2 head 3eb0a962 (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 0b23046c and the end-to-end leg at 665d0599; approval-service.ts is byte-identical at 59bdcb56 (the merge of main touched no plugin-approvals file). Each leg mutated approval-service.ts through scripts/ablation-replace.mjs (anchor hit x1, restore proved by blob == HEAD and an empty git 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 from src, so no build leg applies.

Leg Mutation Red
call the assertDecisionOutputsMatchDeclarations call removed 14 failed
approve_only judged on approve only 1 failed: the reject pin
scalar_for_list the multiple: true list check off 2 failed
list_for_scalar the single-value check off 2 failed
duplicates the repeated-entry check off 1 failed
user_lookup the user record lookup answers "resolves" 4 failed
position_by_name the holder expansion answers "held" 2 failed
user_not_address the slot-address / email guard removed 1 failed
department the department check answers "live" 1 failed
team_missing a missing team row passes 1 failed
team_foreign another organization's team passes 1 failed
end to end the call removed, approval-node.test.ts -t "typed user output" 1 failed, quoted below
text_skipped (at 3eb0a962) text outputs skipped as before 3 failed
text_duplicates (at 3eb0a962) the text repeat check off 1 failed
console_tags (at a913892b7) type: 'tags' → 'text' in decisionOutputParams.ts 4 failed, the dialog test quoting expected { 'outputs.labels': 'gamma', …(1) } to deeply equal { 'outputs.labels': [ …(3) ], …(1) }
console_csv (at a913892b7) the scalar-for-list check off 1 failed: "the comma string the Console used to send … is refused" (Error: expected a refusal)

The end-to-end leg reproduces the card's defect exactly:

AssertionError: expected [Function] to throw error matching /^VALIDATION_FAILED: `outputs\.next_re…/ but got 'RESUME_FAILED: the approve decision w…'
Tests and gates
  • pnpm --filter @objectstack/plugin-approvals test → Test Files 107 passed (107), Tests 1399 passed (1399); typecheck (tsc, scripts tsconfig, check:test-typecheck OK: 8 files / 321 errors held in the ledger, unchanged) → lock VERDICT command-exit 0.
  • Full dogfood (Clause-② carries (narrowing)): closure build turbo run build --filter='@objectstack/dogfood^...' then pnpm --filter @objectstack/dogfood test → Test Files 393 passed, 3 skipped (396); Tests 3243 passed, 28 skipped (3271); lock VERDICT command-exit 0.
  • Lint: root eslint --no-inline-config over packages/plugins/plugin-approvals → exit 0. check:nul-bytes → exit 0.
  • dispatch-gates --commands derived 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-test after fetching its positive-control commit, check:type-check-debt and check:dual-build-cjs-loads after the closure builds. dispatch-gates --ran → "63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED".
  • CI families (required contexts) are read from this PR's check runs, not re-run locally.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 12 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via ApprovalService (symbol, a top-level class))
  • content/docs/permissions/administrator-guide.mdx (via sys_business_unit (literal, a string literal in businessUnitIsLive; a string literal in expandBusinessUnitUsers), sys_team (literal, a string literal in readTeamRow; a string literal in teamIsProvablyOutsideOrg))
  • content/docs/permissions/authorization.mdx (via sys_business_unit (literal, a string literal in businessUnitIsLive; a string literal in expandBusinessUnitUsers))
  • content/docs/permissions/delegated-administration.mdx (via sys_business_unit (literal, a string literal in businessUnitIsLive; a string literal in expandBusinessUnitUsers))
  • content/docs/permissions/permissions-matrix.mdx (via sys_business_unit (literal, a string literal in businessUnitIsLive; a string literal in expandBusinessUnitUsers))
  • content/docs/permissions/positions.mdx (via sys_business_unit (literal, a string literal in businessUnitIsLive; a string literal in expandBusinessUnitUsers))
  • content/docs/permissions/sharing-rules.mdx (via sys_business_unit (literal, a string literal in businessUnitIsLive; a string literal in expandBusinessUnitUsers), sys_team (literal, a string literal in readTeamRow; a string literal in teamIsProvablyOutsideOrg))
  • content/docs/protocol/objectql/security.mdx (via sys_business_unit (literal, a string literal in businessUnitIsLive; a string literal in expandBusinessUnitUsers))

⛔ 6 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v13.mdx (via sys_business_unit (literal, a string literal in businessUnitIsLive; a string literal in expandBusinessUnitUsers))
  • content/docs/releases/v16.mdx (via sys_business_unit (literal, a string literal in businessUnitIsLive; a string literal in expandBusinessUnitUsers))
  • content/docs/releases/v17/17-0.mdx (via sys_team (literal, a string literal in readTeamRow; a string literal in teamIsProvablyOutsideOrg))
  • content/docs/releases/v17/17-4.mdx (via sys_business_unit (literal, a string literal in businessUnitIsLive; a string literal in expandBusinessUnitUsers))
  • content/docs/releases/v17/17-6.mdx (via ApprovalService (symbol, a top-level class))
  • content/docs/releases/v18/18-1.mdx (via ApprovalService (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 38 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json dbca3ef034537269cef482b342aa4ba7169dfe42 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 100d782f466c11b61086327f2f85b292858aa07f — the merge of head f9f2b6f89205cc07b05c3e3a981ca419784b067a into base dbca3ef034537269cef482b342aa4ba7169dfe42, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs dbca3ef034537269cef482b342aa4ba7169dfe42 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Contract-review-claim: session_01ADHkmQKwKFCp1m6Pb6Q8c1 · head 3eb0a96 · 2026-11-02T14:08Z

The narrowing face is reviewed at CONTRACT_REVIEW_TIER by this seat, which dispatched the card, through an at-tier reviewer that is not the drafter. The scope is every path of the diff outside the prose faces.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3eb0a962bb94a01225956da313663e5a1bcc15d0
Local-runs: none
Scope: packages/plugins/plugin-approvals/src/approval-service.ts, packages/plugins/plugin-approvals/src/approval-service.test.ts, packages/plugins/plugin-approvals/src/approval-node.test.ts + packages/rest/src/approvals-routes.ts (handleApprovalError, decisionRoute), packages/spec/src/automation/approval.zod.ts on main (DecisionOutputDefSchema, normalizeDecisionOutputs, the decisionOutputs docblock), docs/adr/0104-field-runtime-value-shape-contract.md (the 2026-10-30 amendment under D2 item 1), examples/app-showcase/src/automation/flows/dynamic-approval.flow.ts, content/docs/automation/approvals.mdx on main, packages/runtime/src/action-execution.ts (buildApiActionRequest, the outputs.KEY fold), ui/packages/app-shell/src/utils/decisionOutputParams.ts and ui/packages/i18n/src/locales/en.ts (the Console's decision dialog, read as the first consumer of the narrowed door)

Face: narrowing. The PR body and the changeset both carry Clause-②: no (narrowing) at the start of a line, and the ruling 6566731522 on #28658 declares the narrowing. No path under packages/spec/src/** and no governed path is in the file list. Check-runs on this head: every run is completed; every conclusion is success or skipped; none failed and none is still running.

① Derived judgments

Each accept-set change the diff implies, judged against the ruling and the code it cites.

  1. user output: a value must be a sys_user record id (reassignTargetRefusal(value, null), no organization screen). Right. It is the predicate the downstream resolveAs: 'user' reader asks (namesNoAccount, approvals: a user approver 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), so the decision refuses exactly what that reader would drop. A user of another organization passes here and routes there too; that asymmetry with the three graph kinds is the downstream reader's, not this diff's.
  2. user output: a slot address (KIND:VALUE) or an email is refused as "not a user id". Right by the ruling's letter ("user ... by record id"). Note: the downstream reader would accept an email and route to it (reassignTargetRefusal resolves email), so this refuses a value the next step could use. The ruling asked for ids; the record notes the gap, no fix.
  3. department output: an active sys_business_unit of the request's organization (businessUnitIsLive, extracted from expandBusinessUnitUsers). Right. The extraction keeps the expansion's behaviour: the seed check still returns [] on a failed read (the try/catch stays at the call site). A request with no organization keeps the unfiltered read, as before.
  4. team output: a sys_team row exists and is not provably another organization's (readTeamRow, extracted from teamIsProvablyOutsideOrg). Right. The extraction keeps the screen's behaviour: a failed read still answers false at the call site; a row with no organization_id passes, as before. Members are not checked; the ruling says "by record id", so this matches.
  5. position output: a machine name someone in the request's organization holds (expandPositionUsers, which also unions sys_member.role tiers). Right. It is the only position resolver the expression approver has; a sys_position id names no holder and is refused, as the spec docblock on main already warns. A membership tier name such as owner passes as a "position"; the downstream reader treats it the same way.
  6. multiple: true takes an array and only an array; multiple absent or false takes a non-array. Right for the four picker kinds. For text and bare keys, see ④ finding 1: it is within the ruling's letter but it breaks the shipped Console.
  7. A list with one entry twice is refused, for pickers and for text. Right. The ruling names duplicates. Entries differing by whitespace or case are not merged; a non-identical picker entry then fails resolution on its own, so nothing slips through as a hidden duplicate.
  8. A non-string or blank-string picker entry is refused ("must hold ... as text"). Right.
  9. A blank value ('', [], null, undefined) is skipped and left to required. Right. The same isBlankDecisionOutput predicate decides both, so the two checks cannot disagree.
  10. A reject carrying a mismatched output is refused; a reject with none is unchanged. Right. decideNode snapshots and resumes with the outputs on a reject too, and the ruling speaks of any admitted output.
  11. A failed directory read refuses nothing. Right and consistent with every resolver in the file. It makes the guarantee best-effort, which the changeset says.
  12. Order: the check runs after the required check and before assertRunResumable and the first engine.insert. Right. Nothing is written before a refusal; a mid-tally vote is judged before it is counted.
  13. A CSV string for a multiple: true output is now refused. Right to refuse (a string is not a list), but the spec docblock on main (approval.zod.ts L1211-1213) spells CSV out as accepted, and the changeset does not name it. See ② should-fix.
  14. Refusal code VALIDATION_FAILED, mapped to 400 by handleApprovalError (packages/rest/src/approvals-routes.ts L126). Right. The ruling's VALIDATION_ERROR is ADR-0104 D2's action-params code; the approvals door has one validation code and the sibling required refusal uses it. Seat answer Q1 = A is right.
  15. Public surface: no export, type or schema changes. The two new methods and two extracted helpers are private; teamOrganizationOf and DECISION_OUTPUT_VALUE_NOUN are module-local. Right. businessUnitIsLive drops the as any on the find options; typecheck is green on this head.

② Semver level

  • Package and level: @objectstack/plugin-approvals at patch, with ! in the summary and a BREAKING banner. Right. AGENTS.md Post-Task step 3: a non-protocol change, breaking or not, ships patch and keeps its banner and ADR-0087 disposition. The diff moves no other released package.
  • Clause-②: no (narrowing) in the PR body and the changeset. Right. The approve and reject doors refuse payloads they admitted before; nothing is widened.
  • Metadata-protocol: none. Right, and this answers the dev's round-2 question. The kind grades what a metadata author can write. This diff refuses no authored key or value and changes no default's declared meaning: on main the multiple describe already says "stored as a list", so an absent multiple already declared one value; the docblock only said the runtime did not check it. What changes is the request door's acceptance of a decision payload, which is the narrowing arm's subject, not the protocol line's. semantic would be owed if the absent default's declared meaning moved; its enforcement moved, not its meaning. The ruling fixed these two lines and the diff matches them.
  • The seat's answer on record ("multiple binds every declaration, text and bare keys included, shape only for text") is within the ruling's text: the multiple sentence names no kind, and the acceptance names "a scalar where a list is declared". But it reaches a consumer the ruling did not weigh. See ④ finding 1.
  • ADR-0087 disposition not-required (no-migration-prescription). Should-fix. The gate passed, but statement against statement the body contradicts the parenthetical: "A step whose bare-key output collects a list declares it { key: 'KEY', multiple: true }" is an instruction to rewrite authored metadata, which the category says a changeset claiming it cannot carry. Either reword the remedy so it is not a rewrite prescription (state that a bare key takes one value, as its declaration always said, and that a CSV string still reaches the downstream expression reader as a list), or pick the disposition that admits the rewrite. No mechanical migration exists (no conversion can know which bare keys carried lists), so the reword is the honest path.
  • Changeset prose. Should-fix, three sentences owed: (a) the "Before" should name the CSV string the spec on main admits for a list output, since it is now refused; (b) the remedy should say what an approver does with a request already pending under a bare-key snapshot in node_config_json, which no re-declaration reaches: send one value (a CSV string, which the downstream reader splits); (c) "Who sees it" should name an author who pairs a picker output with an organization-retargeted expression approver (ADR-0105 D9), whose directory organization the decision cannot know.
  • Skills-docs: line. Should-fix. The pm-dispatch skill owes the PR body a Skills-docs: line for a (narrowing) arm; the body has none. The seat's checklist would REWORK on it; the record names it so the body is corrected with the rest.

③ Boundary flags

Dev flags and open_questions, each answered.

  • Q1 (round 1), refusal code: A, keep VALIDATION_FAILED. One door, one validation code; the ruling's spelling is a slip the spec slice (spec(approvals): the typed decision-output docblock and approvals docs say type/multiple only shape the widget — rewrite to the enforced rule (#28658 ruling A, text slice) #29340, correction 6578262566) already carries.
  • Q2 (round 1), organization for graph kinds: A, the request's organization, accepted for this head. No declaration ties an output to one downstream approver, so the decision has nothing else to resolve in. The limit is real for an author who retargets stage 2 with organization (ADR-0105 D9); name it in the changeset (② should-fix c). Not a fix on this head.
  • Q3 (round 1), position by holders: A. The ruling says "the same resolvers the expression approver already uses", and that approver has only the holder expansion. A held name is the only value that does not route to nobody.
  • Q4 (round 1, seat answered B in round 2), multiple on text: the seat's reading is within the ruling's text, but landing it as delivered breaks the shipped Console for a schema-valid declaration. Escalated as ④ finding 1, must-fix. The seat, or the maintainer if the seat reads the ruling as binding text now, chooses between the two exits named there.
  • Round-2 question, Metadata-protocol: semantic with a migration note: A, none stands, for the reasons in ②. The ADR-0087 parenthetical and the remedy wording are the part to reconcile (② should-fix).
  • Dev deviation, forced test edits: the four edits are each a test whose fixture never named a real record or sent a list under a bare key. Accepted. They are consequences of the narrowing, not test weakening; the new tests pin the refusals the old fixtures would now hit.
  • Dev deviation, ablation legs at older heads: approval-service.ts at 3eb0a962 differs from 59bdcb56 (the text branch was added in round 2), and the dev ran two new legs at 3eb0a962 for that branch. The round-1 legs cover unchanged code. Accepted; the record relies on the check-runs, not on these readings.
  • Landing order: the ruling lands the spec text slice first. PR docs(spec): document typed approval decision outputs as enforced at the decision, not a widget hint #29371 (card spec(approvals): the typed decision-output docblock and approvals docs say type/multiple only shape the widget — rewrite to the enforced rule (#28658 ruling A, text slice) #29340) is open and draft at fc024e80; this PR waits for it. The two texts agree on this head's behaviour (text judged by shape, VALIDATION_FAILED, reject judged).
  • Author: the PR's user.login is objectstack-fleet[bot]; no off-relay note is owed.

④ Design judgment

The narrowing against the ruling and the published spec on main. Cases tried:

  • A decision written before any check: no. Every refusal in decideNode precedes the sys_approval_action insert; the new check sits between required and assertRunResumable. The end-to-end test asserts the action rows stay ['submit'], the request pending, the run suspended.
  • A reject path: judged when outputs are present, unchanged when absent. Right; the outputs ride the reject's resume and snapshot.
  • A multi-approver node: a co-sign vote is judged before the tally reads or writes anything; pending_approvers is unchanged on refusal. Right.
  • A position name vs a sys_position id: the id is refused with a message that says a position is named by its machine name. Right, and it matches the docblock on main that already warned an id "would expand to nobody".
  • A cross-organization id: department, team, position are screened in the request's organization; user is not, as its downstream reader is not. Right by the readers' own contracts.
  • Duplicates after normalisation: identical strings only. A whitespace or case variant of a picker id fails resolution on its own, so no hidden duplicate passes. For text, ['a', 'a '] passes as two values; acceptable, text has no reader that merges them.
  • An output not declared: refused by the existing undeclared-key check before this one. Unchanged.
  • The request staying pending with no decision row: holds, by the order above.
  • The flow never seeing a dead approver: holds for user and position. For team and department the check proves the record, not its members; an empty team or department still reaches onEmptyApprovers downstream. This is what the ruling asked for ("by record id"), so it is noted, not a finding.
  • A failed read: passes, so the guarantee is best-effort. Consistent with the file; the changeset says so.

Findings:

  1. Must-fix. The head refuses the payload the shipped Console sends for a text output declared multiple: true, and the Console has no other way to send it. Read in ui/packages/app-shell/src/utils/decisionOutputParams.ts: every non-picker output, typed text or a bare key, renders as a type: 'text' param with no multiple, under the help text "Handed to the flow as a decision output. Comma-separate multiple values." (ui/packages/i18n/src/locales/en.ts L148). foldDecisionOutputs and buildApiActionRequest pass that one string through unchanged. On main the string is accepted and the downstream expression approver splits it (csvSplit), so a text multiple: true output worked from the Console. At this head assertDecisionOutputsMatchDeclarations refuses it: "outputs.KEY takes a list of values (it is declared multiple: true), not a single value". The step can then be decided only through the API. The shape is schema-valid (multiple is allowed on every type), and the spec slice docs(spec): document typed approval decision outputs as enforced at the decision, not a widget hint #29371 makes { key: 'next_reviewers', multiple: true } the docs page's headline example, so after both land the documented flow cannot be decided from the Console. AGENTS.md Post-Task step 4 ships a change that breaks ui/ together with the ui/ fix or not at all. Two exits, the seat's or the maintainer's call: (a) the Console companion lands with this PR by ruling (the text widget sends an array when multiple: true; the card currently bars ui/**); or (b) the shape check binds the four picker kinds now and text and bare keys only once the Console can send a list, with the seat's Q4 answer and the docs(spec): document typed approval decision outputs as enforced at the decision, not a widget hint #29371 text revised to match. Either way the tree as it stands must not land.
  2. Should-fix. The ADR-0087 parenthetical and the remedy paragraph contradict each other (② above). Reword the remedy or change the disposition.
  3. Should-fix. The changeset's "Before", remedy and "Who sees it" owe the three sentences named in ② (CSV string now refused; the in-flight request under a bare-key snapshot; the organization-retargeted stage 2).
  4. Should-fix. The PR body owes the Skills-docs: line for its (narrowing) arm.
  5. Noted, no fix: user refuses an email the downstream reader would route to; team and department prove the record and not its members; a membership tier name passes as a position. Each matches the ruling's letter or the downstream reader's contract, and the record names them so the next reader does not re-derive them.

Implemented-by: claude/project-thread-rsqu0h
Reviewed-by: session_01ADHkmQKwKFCp1m6Pb6Q8c1

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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Contract-review-claim: session_01ADHkmQKwKFCp1m6Pb6Q8c1 · head c7ca5d1 · 2026-11-02T19:26Z (round 3, final head)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c7ca5d18fa2eddbf9fda9f6b736954f18127b9f6
Local-runs: none
Scope: the round-3 delta 3eb0a962..c7ca5d18 less the origin/main merge 4da16f2a2, which is the 19-file PR file list GitHub reports: ui/packages/app-shell/src/utils/decisionOutputParams.ts (+ .test.ts), ui/packages/app-shell/src/views/ActionParamDialog.decisionOutputList-28658.test.tsx, ui/packages/app-shell/src/views/tests/DeclaredActionsBar.test.tsx, ui/packages/i18n/src/locales/{ar,de,en,es,fr,ja,ko,pt,ru,zh}.ts, .changeset/28658-console-decision-output-list.md (new), .changeset/28658-decision-output-type-multiple.md (reworded), packages/plugins/plugin-approvals/src/approval-service.test.ts (two tests). Re-read for the fold faces: ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx (the Approval Center api handler's outputs.KEY fold), packages/runtime/src/action-execution.ts (buildApiActionRequest's fold), packages/runtime/src/domains/api-action-forward.test.ts, ui/packages/fields/src/widgets/TagsField.tsx, ui/packages/app-shell/src/utils/paramValueShape.ts, ui/packages/i18n/src/locales/en.ts (TranslationKeys), packages/spec/src/automation/approval.zod.ts on main after #29371. packages/plugins/plugin-approvals/src/approval-service.ts, approval-node.test.ts and packages/rest/src/approvals-routes.ts are byte-identical to 3eb0a962 (git diff --stat empty), so the prior record's ① judgments 1-15 stand unchanged and are not re-derived here.

Face: narrowing, as before. Clause-②: no (narrowing) and Metadata-protocol: none stand at the start of a line in the PR body (L7-8) and in both changesets (28658-decision-output-type-multiple.md L7-8; 28658-console-decision-output-list.md L8-9). The PR body now also carries Skills-docs: follow-up #29512 — skills/objectstack-automation/references/state-machines-and-approvals.md (L10); #29512 is open, labelled documentation / pm:queue / domain:skills. No packages/spec/** path and no governed path is in the file list (Governed Surface Queue Guard success). Check-runs on this head: every run completed; every conclusion success or skipped; none failed, none in progress. Check Changeset, Type Check UI, Test UI (1-16/16), Test Core (1-8/8), Build & E2E UI, Live E2E UI, Dogfood Regression Gate, Lint · PM dispatch-gates, PR-body guards are all success; the skipped ones are opt-in or schedule-gated (18.x upgrade harness, Packed-tarball smoke, Temporal Conformance, Build Docs, Dogfood Verify CLI, UI Published Artifacts, Schedule gate).

① Derived judgments

The prior record's ④ finding 1 and ② should-fixes, each against the lines that answer it; then the Console's accept-set faces.

  1. ④ finding 1 (must-fix, the Console sent a comma string for a text/bare-key multiple: true output): closed by exit (a), the Console companion in this PR. ui/packages/app-shell/src/utils/decisionOutputParams.ts L278-285: if (d.multiple === true) { return { ...base, type: 'tags', multiple: true, helpText: t('actions.decisionOutput.help') }; }, reached only after the user (L235), registry select (L245) and lookup (L255) branches, so it covers exactly the free-value outputs: text, a bare key, and a position with no registry reader. The text return (L286-290) is reached only when multiple !== true. TagsField (ui/packages/fields/src/widgets/TagsField.tsx L27-32) stores a string[], trims each entry and skips a blank or an entry already present, which is the same identical-string duplicate predicate the server refuses on. The server pin (approval-service.test.ts L1006-1028) sends the Console-shaped body (next_reviewers: ['u2','u3'], labels: ['alpha','beta','gamma'], watchers: ['ops'], note: 'a, b') under [NEXT_REVIEWERS, {key:'labels',type:'text',multiple:true}, {key:'watchers',multiple:true}, 'note'] and asserts out.outputs equals it; L1030-1034 pins the old comma string refused. Right.
  2. ② should-fix (a), the changeset's "Before" names the CSV string the spec on main admitted: 28658-decision-output-type-multiple.md L15: "Until docs(spec): document typed approval decision outputs as enforced at the decision, not a widget hint #29371 the spec documented that the runtime accepts any value shape for a list output, a CSV string ('u2,u3') included; an API caller that sends that CSV string for a multiple: true output is now refused and sends a list (['u2', 'u3']) instead." Also L25 "a scalar for a list (the CSV string included)". Closed. The wording "Until docs(spec): document typed approval decision outputs as enforced at the decision, not a widget hint #29371" is accurate: approval.zod.ts on origin/main (L1212-1227) now says multiple: true "takes a list with no entry twice ... for every entry (text and bare keys included, judged by shape only)" and names 400 VALIDATION_FAILED.
  3. ② should-fix (b), the in-flight request under a bare-key snapshot: L23 "A request already pending. The judge reads the declaration snapshotted on the request when it opened. A request pending under a bare-key snapshot (decisionOutputs: ['note']) takes one value for that key, so its approver sends one value; nothing about the pending request needs changing." Closed in substance; one clause short. The prior record asked that the sentence also say the one value may be a CSV string that the downstream expression reader splits (approval-service.ts L2758 raw = csvSplit(value) still does). Without it, an approver of a pending bare-key request that was designed to route to several people reads "sends one value" as "can name one person". Residual should-fix, ② below.
  4. ② should-fix (c), "Who sees it" names the organization-retargeted stage 2: L25 "Also an author who pairs a department, team or position picker output with a stage-2 expression approver retargeted by organization (ADR-0105 D9): the decision resolves the picked value in the request's organization, not in the approver's directory organization, so a value that exists only in that other organization is refused at the decision." Closed.
  5. ② should-fix, ADR-0087 parenthetical vs the remedy: the remedy (L28) now reads "A bare key takes one value, as its declaration always said; a step that collects several values declares multiple: true, and the Console now sends a list for it." and the Now bullet (L17) "A bare key (decisionOutputs: ['note']) therefore takes one value, as its declaration always said; a list is what an output declared multiple: true collects." The former "declare { key: 'note', multiple: true } to collect a list" instruction is gone; what remains states what each declaration means. Check Changeset (which runs check:adr-0087-registration's statement-against-statement refusal) is success. Closed.
  6. ② should-fix, the Skills-docs: line: PR body L10 Skills-docs: follow-up #29512 — skills/objectstack-automation/references/state-machines-and-approvals.md. The pm-dispatch skill's form is follow-up owed — FILES | follow-up #N; this line carries both the card number and the file, which is the #N form with the file kept. skills(objectstack-automation): an approval's decision outputs are enforced at the decision — type and multiple are checked, not widget hints (#28658) #29512 exists and is open. Closed.
  7. Record header path sends a list iff multiple: true. decisionOutputParams → resolveActionParams → paramToField → ActionParamDialog → foldDecisionOutputs (decisionOutputParams.ts L303-318), which copies v untouched and drops only undefined/null/''/[]. Cardinality by widget (ui/packages/app-shell/src/utils/paramValueShape.ts L141-160): select, lookup, user are scalar|array on multiple; tags is always string[] and is emitted only under d.multiple === true; text is a scalar. So every kind sends a string[] when multiple: true and a string otherwise. Pinned end-to-end through the real dialog in ActionParamDialog.decisionOutputList-28658.test.tsx L59-83: { 'outputs.labels': ['alpha','beta','gamma'], 'outputs.note': 'a, b' } folded to { labels: [...], note: 'a, b' }. Right.
  8. Approval Center path sends the same. DeclaredActionsBar builds its params through the same decisionOutputParams (DeclaredActionsBar.tsx L493-496), and the api handler fold in useConsoleActionRuntime.tsx L523-530 copies value untouched, dropping undefined/'' only. The server-side forward fold (packages/runtime/src/action-execution.ts L1487-1494) does the same, and api-action-forward.test.ts L647/L684/L701 pin a list value through it. No fold changed in this PR. An empty tags list ([]) reaches the server from this path (the fold drops '' but not []), where isBlankDecisionOutput treats it as blank and leaves it to required (prior ① 9); consistent, no fix. Right.
  9. No path still sends a comma string for a list output, and none sends a list for a single-value output. The comma hint is gone from every locale pack; the only text widget return is under multiple !== true; a bare key has no multiple and stays text (decisionOutputParams.test.ts L208-213); multiple: false stays text (L215-218). A comma typed into a tags box commits the entry (TagsField.tsx L65-68), so it cannot travel inside a list entry by keyboard; a single-value box's comma is literal, which the server test pins as accepted (note: 'a, b'). Right.
  10. position output without a registry reader. With multiple: true it is a tags list of machine names (decisionOutputParams.test.ts L203-206); without, a text box (L279-281). Both are what the server judges for position (machine names, prior ① 5). With a reader it is a select whose multiple follows the declaration (L245-252), unchanged. Consistent across the three renderings.
  11. Retired key actions.decisionOutput.helpMultiValue. Removed from all ten packs (file list); git grep at the head finds it only in the two CHANGELOG.md history lines and the new changeset. Its only call site (decisionOutputParams.ts L268 on main) is gone; the three test expectations that named it move to help. TranslationKeys = typeof en with en ending as const (en.ts L5830, L5833), exported from @object-ui/i18n (src/index.ts L41, src/i18n.ts L268), so a host that names the key through the type gets a compile error. Right, and declared (② below).
  12. Public surface of the delta. decisionOutputParams.ts exports the same seven symbols as on main; app-shell's index.ts does not re-export it; no route file is touched; approval-service.ts is unchanged since the prior head (its two new methods remain private). The locale packs only lose a key. New files are tests and a changeset. No new export, type or route; Clause-② stays no (narrowing).

② Semver level

  • @objectstack/plugin-approvals at patch, ! summary, BREAKING banner, exactly one ADR-0087 marker (28658-decision-output-type-multiple.md L2, L5, L10, L13). Right, unchanged from the prior record (AGENTS.md Post-Task step 3: a non-protocol breaking change ships patch and keeps banner and disposition).
  • @object-ui/i18n and @object-ui/app-shell at patch in 28658-console-decision-output-list.md L2-3, ! summary L6, BREAKING paragraph L13, declaration lines L8-9, exactly one ADR-0087 marker L11. Right. @object-ui/i18n is published (publishConfig.access: public, no private), so the removal from TranslationKeys owes the banner it has: "BREAKING for a TypeScript caller that names actions.decisionOutput.helpMultiValue through the pack-derived key type (TranslationKeys): the key leaves all ten packs". @object-ui/app-shell is private: true; the changeset config's privatePackages.version: true admits a changeset for it, and it exposes no new surface.
  • Step 3's "FROM → TO mapping and the one-line fix" for a removed export is carried as a statement, not a prescription: L16 "Every output's help now reads actions.decisionOutput.help" and the marker's "a host that kept its own text under it keeps it in its own resources". That is the channel the gate's own commentary accepts (the compiler names the missing property), and it keeps no-migration-prescription honest (the gate passed). Accepted.
  • ADR-0087 not-required (no-migration-prescription) on both changesets: the plugin-approvals remedy no longer prescribes a rewrite (① 5); the Console marker names the one deleted key and says why it is not metadata. Statement against statement, neither body contradicts its parenthetical. Right.
  • Clause-②: no (narrowing) on the Console changeset: the Console widens nothing and the TranslationKeys type loses a member, which is the narrowing arm (the closed pair makes (narrowing) the BREAKING arm). Right.
  • Should-fix (one sentence). 28658-decision-output-type-multiple.md L23 says the approver of a pending bare-key request "sends one value"; add that the one value may be a comma-separated string, which the downstream expression reader still splits into a list (approval-service.ts L2758), so a pending request designed to route to several people is not stranded by the narrowing. Prose only; no code moves.

③ Boundary flags

  • Prior ④ finding 1, exit (a) vs (b): the dev took (a), the Console companion in this PR. The card's earlier ui/** bar is answered by AGENTS.md Post-Task step 4 (a change that breaks ui/ ships with the ui/ fix or not at all), which the prior record cited as the ground for the must-fix; the PR body (L31) and the file list name the ui/ files openly. Accepted.
  • Skills-docs: line: present, follow-up #29512 — <file>. The seat's checklist (.claude/skills/pm-dispatch/references/review-checklist.md L18) asks that it be present and true; the named file is the automation skill's approvals reference, which is the place the enforced-at-decision rule belongs. Accepted.
  • PR body consistency: the 维护者速读 and "What changes" were rewritten in round 3 to name the Console change, the two-place rollback, docs(spec): document typed approval decision outputs as enforced at the decision, not a widget hint #29371 landed, and the two changesets; the Tests section names the five new/changed test groups. Read against the diff: it matches. (An earlier copy of the body, read at the start of this review, still said text was "完全不变"; the current body, updated_at 2026-11-02T19:27:17Z, does not.)
  • Landing order: docs(spec): document typed approval decision outputs as enforced at the decision, not a widget hint #29371 is merged into main and into this branch (4da16f2a2); the spec docblock on main (approval.zod.ts L1212-1227) and this head's behaviour agree (every entry judged by shape, pickers resolved, 400 VALIDATION_FAILED, before any write).
  • The dev's verification claims (dogfood 3277/0, ui app-shell 18804, i18n 1443, 67/67 gates) are not relied on; the record relies on the head's check-runs, all green.
  • Author: objectstack-fleet[bot]; no off-relay note owed. Draft state: draft: true, which the seat's landing step changes, not this record.

④ Design judgment

The Console change against the narrowed door, cases tried:

  • A text output declared multiple: true, decided from the record header: tags list → foldDecisionOutputs → outputs.KEY: string[] → accepted (server pin L1006-1028). Holds.
  • The same from the Approval Center: tags list → api handler fold (useConsoleActionRuntime.tsx L523-530) → the same body. Holds; both surfaces build from one function.
  • A bare key sent 'a, b': one string, comma literal, accepted (note: 'a, b'). Holds; nothing in the Console or the server splits it.
  • A user/department/team output: picker ids, scalar or array on multiple, as before; unchanged by this round.
  • A position with no registry reader and multiple: true: machine names as a tags list; with multiple absent, one machine name. Holds.
  • A duplicate typed twice into the tags box: skipped at entry (tags.includes(t) after trim), so the server's identical-string duplicate refusal is never reached from the Console. Holds. A case or whitespace variant stays two entries, as the server also treats it (prior ④).
  • An empty tags list on an optional output: [] dropped by foldDecisionOutputs, forwarded by the api handler and treated as blank by the server; both end at required. Consistent.
  • A host that calls t('actions.decisionOutput.helpMultiValue') through TranslationKeys: compile error on upgrade, named in the changeset's BREAKING paragraph; at runtime i18next would render the key or the fallback, and no shipped code reaches it. Declared.
  • The documented headline example after docs(spec): document typed approval decision outputs as enforced at the decision, not a widget hint #29371 ({ key: 'next_reviewers', multiple: true } on a user output) decides from the Console with the user picker's array; a text list example decides with the tags list. The prior record's "the documented flow cannot be decided from the Console" no longer holds.

Findings:

  1. Should-fix. 28658-decision-output-type-multiple.md L23: say that the one value an approver sends for a pending bare-key request may be a comma-separated string that the downstream expression reader splits (approval-service.ts L2758), so the pending-request paragraph answers the case it was added for. Prose only.
  2. Noted, no fix: the Approval Center's api handler fold forwards [] where the record header's fold drops it; the server treats both as blank. The two folds were already asymmetric on main and the outcome is the same.
  3. Noted, no fix: TagsField commits an entry on the , key, so a tag containing a comma can only be pasted, not typed. Not a contract face; the server accepts any text entry.
  4. Closed from the prior record: ④ finding 1 (must-fix), ② ADR-0087 wording, ② changeset sentences (a) and (c), ② Skills-docs: line. Sentence (b) is the residue in finding 1.

Implemented-by: claude/project-thread-rsqu0h
Reviewed-by: session_01ADHkmQKwKFCp1m6Pb6Q8c1

Exit: (b) PASS with should-fix — (1) one sentence in .changeset/28658-decision-output-type-multiple.md L23: the one value for a pending bare-key request may be a CSV string the downstream expression reader still splits.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f9f2b6f89205cc07b05c3e3a981ca419784b067a
Local-runs: none
Delta-of: c7ca5d18fa2eddbf9fda9f6b736954f18127b9f6
Scope: git diff c7ca5d18..f9f2b6f8, one commit (f9f2b6f89 docs(changeset): a pending bare-key request may still send one comma string), one file, 1 insertion / 1 deletion: .changeset/28658-decision-output-type-multiple.md L23. No code, test, locale or PR-body-governed path moves; the record-r3 judgments on c7ca5d18 stand as written.

Findings:

  1. The should-fix is closed. L23 now ends: "That one value may be a comma-separated string ('u2,u3'): a bare key is text, judged by shape only, and a downstream expression approver still splits a string value into a list, so a pending request meant to route to several people is not stranded." That is the sentence record-r3 ② asked for.
  2. Accurate against the code. packages/plugins/plugin-approvals/src/approval-service.ts L1285-1289 csvSplit splits a string on ,, trims and drops blanks; the expression approver's normalisation at L2755-2758 calls raw = csvSplit(value) for a string value before resolveAs expansion, so a bare-key text value 'u2,u3' reaches the next stage as ['u2','u3']. A bare key is judged by shape only (record-r3 ① / prior record ① 6, 19), and a single string passes the single-value shape, so the decision admits it. Both halves of the sentence hold.
  3. No contradiction introduced. "Who sees it" (L26) still names "a scalar for a list (the CSV string included)": that is a multiple: true declaration, where a CSV string is refused; L23 speaks of a pending single-value bare key, where it is admitted. The two lines describe different declarations and agree with the code. The sentence states what a pending request can do; it instructs no rewrite, so the ADR-0087 not-required (no-migration-prescription) marker (L10) is not contradicted statement-against-statement.
  4. Declarations unchanged. Clause-②: no (narrowing) (L7), Metadata-protocol: none (L8), patch for @objectstack/plugin-approvals (L2), one ADR-0087 marker (L10). No new export, type or route.
  5. Check-runs on this head, read at 2026-11-02 ~19:4x UTC: still running. 21 runs listed: 1 completed/success, 3 completed/skipped, 17 in_progress (Check Changeset, Check PR Size, Flag docs affected by code changes, the seven Lint · runs, PR-body guards, the four Type Check · runs, filter); the Test Core / Test UI / dogfood / E2E families had not yet been scheduled by the filter run. The delta is a changeset body edit, and the one gate that reads it (check:adr-0087-registration under Check Changeset, Lint · CI, release & engine gates) was green on c7ca5d18; the landing step re-reads this head's runs before arming, as the seat's checklist requires. Nothing failed at the time of reading.

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.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

维护者速读

  • 改了什么:审批「决策输出」按声明的 type 和 multiple 当场校验:选人、选部门、选团队要能查到真实记录,岗位要有人担任;multiple: true 只收不重复的列表,没声明只收单个值。不合格返回 400 VALIDATION_FAILED,什么都不记录。Console 同步改为对 multiple: true 的文本输出发字符串数组。
  • 为什么改:approvals: a typed user decision 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 卡死。
  • 风险与回滚:收窄(BREAKING,已在两个 changeset 写明);已挂起的裸键请求仍可发一个逗号串,下游表达式审批人照旧拆分。回滚即 revert 本 PR(服务端和 Console 一起撤)。
  • 先看:packages/plugins/plugin-approvals/src/approval-service.ts 的 assertDecisionOutputsMatchDeclarations;ui/packages/app-shell/src/utils/decisionOutputParams.ts L278-290。
  • 两分钟验证:pnpm --filter @objectstack/plugin-approvals exec vitest run src/approval-service.test.ts -t "typed decision outputs",22 条全绿。
  • 需要你做的:无,席位落地(775 行,未过 3000 行线)。

席位意见:与裁决 A 及席位答复 Q1–Q4 相符。第 2 轮合同复审提出的 Console 断裂(必改)由本 PR 的 Console 同步关闭;第 3 轮仅剩的一句 changeset 补充已由 f9f2b6f 落实,增量复审 PASS。

席位核对

ACCEPT · domain:server seat 5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 · 2026-11-02T19:41Z · head f9f2b6f892 · Fixes #28658

  1. Shape: draft against main, Fixes #28658. Two patch changesets: @objectstack/plugin-approvals, and @object-ui/app-shell with @object-ui/i18n. Both carry Clause-②: no (narrowing), Metadata-protocol: none, and one ADR-0087 not-required (no-migration-prescription) marker, with the BREAKING paragraphs named. Not governed (check-governed-merges: 19 paths clear). +734/−41 across 19 files. Skills-docs: follow-up #29512.
  2. Against the order: ruling A (6566731522) and seat answers Q1–Q4. The round-3 contract review on c7ca5d18f exited (b) with one changeset sentence. The delta review on f9f2b6f89 exited (a) PASS; the diff is changeset-only. No new export, type or route.
  3. Accepted deviations: four forced test edits are named in the body: two old values that named nothing, and two bare keys that were sent a list. The retired i18n key actions.decisionOutput.helpMultiValue is gone from all ten packs and has no call site.
  4. Gates: the dev reports, on c7ca5d18f, 67/67 derived gates exit 0 with 0 NOT-MEASURED; local dogfood at 3277 passed (dogfood is skipped on PRs, so it was run locally); plugin-approvals at 1404 passed with tsc green; ui app-shell at 18804 passed; i18n at 1443 passed; and the type-checks and i18n checks exit 0. On f9f2b6f89 the changeset gates (ADR-0087 registration, no-major, empty-changeset, changeset self-tests) exit 0. 16 ablation legs, each red.
  5. Out of scope: the skills doc goes to skills(objectstack-automation): an approval's decision outputs are enforced at the decision — type and multiple are checked, not widget hints (#28658) #29512. A downstream expression approver retargeted by organization (ADR-0105 D9) resolves elsewhere; this is named in the changeset as a known edge, with no fix here.
  6. Landing: pr_ready + automerge_enable through the relay once no check-run on this head is red or running.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 19:57
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit d3175d5 Nov 2, 2026
64 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-rsqu0h branch November 2, 2026 20:15
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/l tests tooling

Projects

None yet

1 participant