Skip to content

fix(ui): judge requiredWhen as the engine's transition gate on the record Edit form - #30216

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-30184-requiredwhen-transition-gate
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-30184-requiredwhen-transition-gate

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

domain:ui#2 · os-justin · session_01QSeKYnE9YNkSZen7sijLA3
Fixes #30184

Clause-②: yes (widening)
Metadata-protocol: none

This widens the public surface of @object-ui/core: the new export requiredWhenRestsOnStoredRecord and the new requiredWhenAtRest field in the return type of resolveFieldRuleState.

维护者速读

  • 改了什么:Console 记录编辑表单按引擎的方式判 requiredWhen(转移闸门):已存记录本来就处在闸门内且缺值时,不再挡住无关编辑;创建表单与「把记录移入闸门」的编辑照旧拦截。
  • 为什么改:HotCRM 实测,合同在签字件规则出现前就已激活,REST PATCH 返回 200,但 Console 编辑一律报「Signed Copy is required」,存量记录在 Console 里改不了。
  • 风险与回滚:只改客户端判定,服务端不动;最坏情况是客户端放行、服务端拒绝,拒绝信息照常落在字段上。回滚即还原本 PR。
  • 先看:ui/packages/core/src/evaluator/fieldRules.ts(resolveFieldRuleState 的 requiredByRule / requiredWhenAtRest 段)、ui/packages/components/src/renderers/form/form.tsx(渲染路径传入 name,与动作提示的 !ruleState.requiredWhenAtRest)、form-required-when-submit.test.tsx 新增的 describe。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/components/src/renderers/form/__tests__/form-required-when-submit.test.tsx,预期 9 个用例全绿,其中「saves an unrelated edit to a record already in the gate」通过。
  • 需要你做的:无,席位落地。

What changes

The engine's rule, read on origin/main 31fe289f: objectql evaluateValidationRules (packages/objectql/src/validation/rule-validator.ts, the "ADR-0113 non-regression: reject iff the MERGED state violates AND the PRE state complied" block) refuses an update only when the predicate holds over the merged record with the field missing AND the stored row did not already violate it (the preViolated check). A predicate that cannot be evaluated over the stored row counts it as compliant. An insert complies vacuously. Field.requiredWhen in @objectstack/spec states the same contract: "the write is refused only when the merged record violates the requirement AND the pre-write record complied".

The client side is re-bound to that rule in the one place every form verdict comes from:

  • @object-ui/core resolveFieldRuleState takes statics.name. With previous (the stored row of an UPDATE) and name, a stored row that already violates requiredWhen resolves required: false and the new requiredWhenAtRest: true. A caller that passes no name keeps the plain verdict. The stored-row check is exported alone as requiredWhenRestsOnStoredRecord. "Missing" is isMissingForRequired, the same answer the client's required check gives.
  • @object-ui/components form renderer passes name at the render path (asterisk, aria-required and the submit validator all read that one verdict) and at the stale-error cleanup that reads required. The update-action heads-up (the "Required to run" note, objectstack#28382) is suppressed when requiredWhenAtRest is true: the engine judges an action's write against the same stored row, so the action is not refused for that field either.

Chosen: mirror, not defer. Everything the rule reads is in the form: the Edit form's previousValues is the record from findOne with no $select, seeded with null for every declared field, the same assembly the live evaluation already uses. Where the form's stored row could differ from the server's (a column the caller cannot read), the client answers "at rest" and lets the write through, and the server's field-attributed refusal still lands at the field through the existing extractFieldErrors path. The client is never stricter than the engine here, except for the existing empty-array case (the client counts [] as missing, the engine does not), which this PR does not change.

Create forms (previousValues absent) are unchanged.

Tests

  • New top-level describe in form-required-when-submit.test.tsx (the real form renderer): (a) an already-activated record with no signed copy saves an unrelated edit; (b) moving a record into activated without the copy is refused in the form; clearing the copy of a record that held it is refused; (c) a Create form still enforces.
  • New case in form-required-for-action-28382.test.tsx: a stored row already inside the gate without the value shows neither the asterisk nor the action note, and Save writes.
  • New describe in core fieldRules.test.ts: the six verdict cases, including no name keeps the plain verdict, static required: true is never relaxed, and a fault over the stored row counts it as compliant. The existing "all three compose" toEqual gains requiredWhenAtRest: false.

Acceptance notes

  • Same verdict, callers that do not pass name yet (same defect class, outside this claim's file surface, so not edited):
    • Console FormPage (ui/apps/console/src/components/FormPage.tsx, resolveRowState) edits through /forms/:name?recordId= with the stored record as previous; it gets the fix by adding name: field.name to its statics.
    • plugin-form WizardForm gateFinalSubmit passes no previous in either mode, deliberately ("persistedRecord is deliberately NOT bound here", objectui#8069's ruling). An Edit wizard's steps render through the fixed form renderer, but its cross-step final gate still refuses a stored row already in the gate. Fixing it means using persistedRecord for the stored-row check, which that note forbids today, so it needs the seat's word.
    • plugin-kanban collectRequiredWhenPromptFields passes the pre-drop record as previous and no name, so a drop still prompts for a field the engine would let rest.
  • The file header of fieldRules.ts still says an unevaluable requiredWhen "is skipped on BOTH ends". That has been stale since ADR-0137 D2 (the engine refuses). Not changed here.

Verification

Head: 0e6a6f475 (every reading below was taken on this head; base 31fe289f). c03f4ecac after it changes only the changeset's Clause-②: line and adds one sentence; the changeset gates were rerun on it (check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check:changeset-fixed, check:nul-bytes, all exit 0).

Targeted tests (core evaluator + every ui test naming requiredWhen / resolveFieldRuleState / aria-required)
pnpm --dir ui exec vitest run --maxWorkers=2 packages/core/src/evaluator/ (+ 61 named files)
 Test Files  82 passed (82)
      Tests  1711 passed | 27 skipped (1738)
os-verify-lock: VERDICT command-exit 0
Ablation: transition check removed (anchor statics.name !== undefined replaced by false), restored to HEAD
ablation-replace: ok mutation landed: anchor 1 -> 0, blob f7e06a716bca -> dbbe71790dc3
     × saves an unrelated edit to a record already in the gate without the value
     × notes nothing when the stored row already lacks the value inside the gate (objectstack#30184)
     × an already-activated stored row with no value rests: not required, at rest
 Tests  3 failed | 77 passed (80)
ablation-replace: ok restored: blob == HEAD (f7e06a716bca) and `git diff HEAD` is empty

Action-note half alone (the !ruleState.requiredWhenAtRest gate deleted in form.tsx): the action-note case goes red (1 failed, 13 passed), restored, blob == HEAD.

A first ablation attempt was a no-op: its replacement contained the anchor, so ablation-replace refused it and ran nothing.

Typecheck
pnpm --filter @object-ui/core --filter @object-ui/components run type-check
ui/packages/core type-check: Done
ui/packages/components type-check: Done

Both scripts run tsc -p tsconfig.test.json, so the new tests are compiled.

Derived gates (dispatch-gates --commands, 35 + check:changeset-fixed)
node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-empty-changeset.mjs --base origin/main :: exit 0
node scripts/check-empty-changeset.mjs --self-test :: exit 0
node scripts/check-sdui-manifest.mjs :: exit 0
node scripts/check-sdui-manifest.mjs --self-test :: exit 0
node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0
node scripts/release-pending-publish.mjs --self-test :: exit 0
pnpm --dir ui exec vitest run scripts/ :: exit 1
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:dispatcher-error-vocabulary :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:engine-double-contract :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:published-files :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:registry-log-declared :: exit 0
pnpm check:rest-log-spy-declared :: exit 0
pnpm check:skill-identifier-liveness :: exit 0
pnpm check:test-source-alias :: exit 0
pnpm check:tier-file-adoption :: exit 0
pnpm check:type-check-coverage :: exit 0
pnpm check:type-check-debt :: exit 0
pnpm check:undeclared-dep-imports :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0
pnpm check:changeset-fixed :: exit 0

dispatch-gates --ran: "35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED (a DERIVED zero — all 35 recorded an exit code and none of them is 3)".

  • pnpm --dir ui exec vitest run scripts/ exited 1 on the full run (3 failed of 4987): two cases of date-only-zone-pins-10183.test.ts (a forked plugin-calendar child) and one 15 s timeout in js-comment-mask-param-types-9324.test.ts. Neither reads a file this PR changes. Rerun alone on the same head under the lock: Test Files 2 passed (2), Tests 39 passed (39), VERDICT command-exit 0. Read as shared-box load; CI's run is the record.
  • pnpm check:type-check-debt first returned exit 3 (PREREQUISITE NOT MET, lock queue timeout, nothing measured); rerun under the lock: "0 raw tsc error(s) total, none above its recorded number", exit 0. The table records the rerun.

Generated by Claude Code

…Clause-② widening

Claude-Session: https://claude.ai/code/session_01QSeKYnE9YNkSZen7sijLA3
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-justin · session_01QSeKYnE9YNkSZen7sijLA3

维护者速读

  • 改了什么:Console 编辑表单对字段的 requiredWhen 改为与引擎同一判法:只有这次编辑把记录推进门内时才要求;记录本来就在门内(如已激活但缺签字件的合同),改无关字段可以保存。新建表单不变;「执行操作前必填」的提示同用这一判断。
  • 为什么改:HotCRM 18.3.0 实测,已有数据加了转换门后,这些记录在 Console 里改什么都存不了,而 REST 同样的写入 200。
  • 风险与回滚:只动前端判定,不动服务端与 spec;回滚即撤本 PR。
  • 先看:ui/packages/core/src/evaluator/fieldRules.ts 的 requiredWhenRestsOnStoredRecord 与 resolveFieldRuleState 的 requiredWhenAtRest;components/.../form.tsx。
  • 两分钟验证:三个新用例(已在门内可改无关字段;推进门内仍拒;新建仍强制)。
  • 需要你做的:无,席位落地。

席位意见:ACCEPT。

席位核对

  • Verdict: ACCEPT · Head-sha: c03f4ecac99cfbfeca34b6ebf613531b0cd547c2 · 2026-11-05T02:33Z
  • 形态:draft → main,Fixes #30184 唯一闭合关键词。
  • 前提实测:引擎规则 packages/objectql/src/validation/rule-validator.ts 的 ADR-0113 非回退块(合并后违反且原记录合规才拒)由 dev 读出;席位读 diff:requiredWhenAtRest = requiredByRule && name 已知 && 原记录字段为空且原记录上谓词为真 ⇒ required:false,与引擎同义。
  • 门禁:ci-failure --pr 30216 GREEN,55 项、0 失败;check-expected-skips OK(13/13);check-governed-merges:非受管,+255/−16。
  • Clause-②:yes (widening)(认领 6646361579 已追加式更正):@object-ui/core 入口新增导出 requiredWhenRestsOnStoredRecord,resolveFieldRuleState 返回值新增 requiredWhenAtRest,均为增量;本记录即该申报的席位复核(TIER_DEFAULT)。表单放宽属拉回引擎已声明规则,不计入。
  • 接受的偏差:未先合 main(新提交不触改动文件);本地 vitest scripts/ 全量 3 个失败在改动外文件,单跑通过。
  • 范围外去向:向导最终校验 / FormPage / 看板拖拽弹窗三处同族 → 已立收口卡 requiredWhen is still judged against current values in three more form doors: the edit wizard, the console FormPage, and the kanban drop prompt #30220;fieldRules.ts 头注过时 → Acceptance notes。Skills-docs: 不适用(Metadata-protocol: none)。
  • 落地条款:非受管、≤3000 行 ⇒ 席位经中继落地(维护者授权见本会话)。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 02:34
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 5, 2026 02:35
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit bff5ba2 Nov 5, 2026
60 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-30184-requiredwhen-transition-gate branch November 5, 2026 02:53
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/m tests tooling

Projects

None yet

1 participant