Skip to content

fix(app-shell): publish review sheet lists no Options change on a picklist-bound field nobody edited - #29949

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-xkxln8
Nov 3, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-xkxln8

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #29899

维护者速读

  • 改了什么: Studio 发布审阅面板在比较对象的已发布版和草稿时,把绑定了 picklist 的字段都按"存储形"(不带 options)来比,不再把服务端解析出来的选项当成作者改动。
  • 为什么改: 只要对象里有绑定 picklist 的字段,每次发布面板都会多出一行"~ Tier · Options",作者根本没改过,面板失去可信度,真改了也分辨不出来。
  • 风险与回滚: 只动 @object-ui/app-shell 的面板 diff,不改任何写入、协议或存储;只影响声明了 picklist 的字段的 options 比较。回滚即 revert 本 PR。
  • 先看: ui/packages/app-shell/src/preview/DraftChangesPanel.tsx 的 computeChangeDetail 开头的 stored(...) 两行;新测试 DraftChangesPanel.picklistServedOptions-29899.test.tsx。
  • 两分钟验证: pnpm --dir ui exec vitest run packages/app-shell/src/preview/__tests__/DraftChangesPanel.picklistServedOptions-29899.test.tsx,预期 3 passed。
  • 需要你做的: 无,席位落地。

Clause-②: no
Metadata-protocol: none

What changes

computeChangeDetail (the diff behind Studio's publish review sheet) now reads both versions of an object in the form the publish door stores a picklist-bound field: without options. It does so by passing each envelope-free body through dropServedPicklistOptions from @object-ui/data-objectstack, the function the Studio and Setup writers already use to turn a served object read back into the authored form before a PUT (objectui#10202, objectui#11692). The step runs only when the entry's type is object.

The panel already imports from @object-ui/data-objectstack (readDraftChanges), so the console's eager graph gains no new module.

Why "compare like with like (both stored)" and not "skip options in the diff"

The card offered both. Normalizing to the stored form keeps every real change visible; skipping a key would hide some.

  • dropServedPicklistOptions touches a field only when that definition names a picklist (picklist !== undefined, the FieldSchema refinement's own test). A field with inline options is returned by reference, so a real options change there is still listed. The CONTROL test proves it.
  • A field moving between a picklist and inline options still lists both picklist and options, in either direction, because the side with inline options keeps them. A diff-level "ignore options when the field declares picklist" rule would hide the options half of that move. It would also have to choose which side's picklist counts.
  • The authoring door refuses picklist and options together, so the stored form of a bound field never carries options. Normalizing to it loses nothing an author could have written.

Mechanism, measured against origin/main at 21510bba

The card's PM assumptions hold:

  • The live read carries the resolved options; the draft read does not. readMetaItem (packages/metadata-protocol/src/protocol-meta-item-reads.ts) returns early for state: 'draft' ("Draft reads stop here"). It serves the converted draft row through governServedObject and never reaches the object fold. The active read folds the object through foldObjectExtendersFromRegistry → SchemaRegistry.foldObjectExtendersOnto → finishServedObject → resolvePicklistFieldsOnto (packages/objectql/src/picklist-resolution.ts). That last step writes the resolved options onto each bound field and keeps picklist.
  • No path found where the draft itself carries resolved options. Studio's writers drop them before a save (dropServedPicklistOptions), and the authoring door refuses a body that carries both keys. A body that did carry both would also be normalized here on both sides.
  • The fix is not server-side. Serving the resolved options on a bound field is the declared contract (PicklistServedFieldSchema, @objectstack/spec data/picklist.zod.ts): every object read serves it, and the write door judges values by the same served field. The draft read serving the stored row is ADR-0005's draft lifecycle. Neither read is wrong. The diff was comparing two forms, so the ui lane owns the fix.

Tests

New file ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.picklistServedOptions-29899.test.tsx. It renders the real DraftChangesPanel against routes where the published read serves tier with resolved options and the draft read serves it stored:

  1. A picklist-bound field tier, and a draft that changes only notes.label: the drill-in renders exactly one row, ~ Again notes notes · Label, and no Options.
  2. CONTROL: an inline-options select status whose options really changed, beside the same bound tier: exactly one row, ~ Status status · Options.
  3. computeChangeDetail direct: a bound field reads unchanged; bound → inline and inline → bound each list both picklist and options; the array fields shape reads the same.
Ablation (fix removed → new tests red → restored)

Fix committed first (f4d64a21). Then node scripts/ablation-replace.mjs swapped the anchor type === 'object' ? dropServedPicklistOptions(body) : body; for body; // ABLATION-29899 and ran the new file under the verify lock. The test imports the panel by relative path from src, so no dist rebuild sits between the mutation and the run.

ablation-replace: ok mutation landed: anchor 1 -> 0, blob 694ec6852677 -> eb00fb8b831c
     × lists only the label change when the draft edits another field of an object with a bound field
     × CONTROL: still lists an Options change on a field with inline options
     × reads a bound field as unchanged in computeChangeDetail, and keeps a move to or from inline options
AssertionError: expected [ '~ Again notes notes · Label', …(1) ] to have a length of 1 but got 2
AssertionError: expected [ '~ Status status · Options', …(1) ] to have a length of 1 but got 2
AssertionError: expected [ { name: 'tier', …(1) } ] to deeply equal []
 Test Files  1 failed (1)
      Tests  3 failed (3)
ablation-replace: ok restored: blob == HEAD (694ec6852677) and `git diff HEAD` is empty

Without the fix, case 1 renders a second row beside the label row, and case 3 lists tier as changed. The CONTROL fails only on its row count: its own Options row is still there first, and one more row follows it.

Acceptance notes

  • Scope: this changes only what the review sheet displays. The publish door's own no-op test (content hashes of the stored bodies) never read served options and is unchanged.
  • type gate: only object bodies are normalized. Its one caller passes entry.type. The served picklist form is a property of object reads.
  • No out-of-scope findings.

Verification

All runs at head f4d64a21, from the ui/ root as ui/AGENTS.md prescribes, under scripts/pm/os-verify-lock.sh.

Local runs
Command Verdict
turbo run build --filter='@object-ui/app-shell^...' --concurrency=2 (closure) VERDICT command-exit 0, 64/64 tasks
pnpm --filter @object-ui/app-shell build VERDICT command-exit 0
pnpm --filter @object-ui/app-shell type-check VERDICT command-exit 0
pnpm --dir ui exec vitest run --maxWorkers=2 over the 84 test files that name DraftChangesPanel VERDICT command-exit 0, Test Files 84 passed (84), Tests 536 passed (536)
node scripts/check-sdui-manifest.mjs exit 0, manifest intact (106 components)
New test file alone 3 passed (3)

Narrowed: the full @object-ui/app-shell vitest suite is not run locally. It needs more than an hour on the shared box. The 84 files are every test that names the edited component. CI's Test UI runs the rest.

Derived gates (`dispatch-gates.mjs --commands`)

35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN (dispatch-gates --ran exit 0) at f4d64a21. Each line is CMD :: exit N.

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 0
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

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzfC4w4tZzdzVyV5FGX

…stored form

The live read serves a picklist-bound field with the options the runtime
resolved from its picklist; the strict draft read serves the stored draft,
which never carries them. The sheet diffed the two as read and listed an
Options change on every publish. Both sides of an object now go through
dropServedPicklistOptions before the diff, so inline options keep every
real change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfC4w4tZzdzVyV5FGX
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tooling labels Nov 3, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-support-ai · session_01WGufzjDyeFwq6AuFu3vwTd

维护者速读(席位终稿)

  • 改了什么:Studio 发布审阅面板比较对象的已发布版和草稿时,绑定了 picklist 的字段两边都按「存储形」(不带 options)来比,不再把服务端解析出的选项当成作者的改动。
  • 为什么改:只要对象里有绑定 picklist 的字段,每次发布都会多出一行「~ Tier · Options」,作者根本没改过;面板失去可信度,真改了也分不出来。
  • 风险与回滚:低。只改 @object-ui/app-shell 面板的比较,不动写入、协议和存储;只影响声明了 picklist 的字段的 options;回滚即 revert。
  • 先看:ui/packages/app-shell/src/preview/DraftChangesPanel.tsx 中 computeChangeDetail 开头的 stored(...) 两行。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/app-shell/src/preview/__tests__/DraftChangesPanel.picklistServedOptions-29899.test.tsx,3 个测试通过。
  • 需要你做的:无,非受管面,CI 全绿后席位落地。
  • 席位意见:ACCEPT。复用写入方已有的「服务形 → 存储形」函数,内联选项的真实改动仍会列出(有正控)。

席位核对 · ACCEPT · head f4d64a217fb7cbe409d1b43dc023444ae5941130 · 2026-11-03T16:43Z

  1. Shape: draft, base main, attribution block then Fixes #29899 (whole card lands). One changeset (@object-ui/app-shell patch) with Clause-②: no / Metadata-protocol: none; no content/docs/releases/; 3 files, +206/−3. check-governed-merges --pr 29949: NOT governed, 209 lines ≤ 3000. No contract face, so this seat's read is the review of record.
  2. Premise, measured: read in the diff and on origin/main 21510bba: computeChangeDetail compared both bodies as read. The fix runs both through dropServedPicklistOptions (ui/packages/data-objectstack/src/picklist-binding.ts:67, already exported at index.ts:7948, already imported by this panel's module for readDraftChanges). That helper strips options only where the field names a picklist, handles both the keyed and the array fields shape, and returns unchanged bodies by reference, so inline-options fields keep every real change. Scoped to type === 'object'. The dev measured the server side (draft read stops before resolvePicklistFieldsOnto); no server fork.
  3. Gates: dev's local union at f4d64a21: app-shell build + type-check, 84 DraftChangesPanel test files / 536 tests, check-sdui-manifest, dispatch-gates --commands 35/35 exit 0; ablation 3/3 red then restored. CI on this head was in progress at this reading; landing waits for every check green or expected-skip.
  4. Accepted deviation: none.
  5. Out of scope: none reported. Skills-docs: not owed. mcp_calls: 0.
  6. Landing: ordinary queue. When every check on this head is green or expected-skip: relay pr_ready + automerge_enable, follow to MERGED, record the landing on Studio publish sheet lists an "Options" change for a picklist-bound field nobody edited, because it compares the stored draft with the live read that carries the resolved options #29899.

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 tooling

Projects

None yet

1 participant