Skip to content

fix(app-shell): 重置/删除按钮渲染它真正执行的动词 —— 渲染侧与执行侧同读服务端 verdict (#4886) - #4903

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4886-lock-verdict
Aug 17, 2026
Merged

fix(app-shell): 重置/删除按钮渲染它真正执行的动词 —— 渲染侧与执行侧同读服务端 verdict (#4886)#4903
yinlianghui merged 1 commit into
mainfrom
claude/issue-4886-lock-verdict

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4886

按维护者 2026-08-17 裁定实施 A 案(渲染语义胜出,经服务端 verdict 实现)

缺陷

一颗按钮,两份各自手搓的 artifact 判定。渲染侧问页面的两级判定 isArtifactItem(排除 sys_metadata 保存路径哨兵与 ADR-0010 provenance: 'org'),doReset() 却自己另算一份更松的 layered?.code != null。对已发布的组织自有条目二者必然分叉 —— 该条目的 code 层正是它自己被重新注册回来的 sys_metadata 行:按钮画出垃圾桶、title 写「Delete」,点下去却问「Reset overlay for …?」,然后走重置分支(重拉 layered、留在本页),把操作者留在一个刚刚被请求销毁掉的条目的页面上。

PR #4885 既非引入方也未修复它,但扩大了能看见这颗按钮的人群。

两项派发前提测量(写代码之前完成,读数如下)

两项都是对 framework 仓 origin/main 实测的(注意:容器里共享 checkout 的 HEAD 落后于 origin/main,前后两次读数已按 origin/main 复核)。

client.reset() 对无 artifact 基线条目的实际行为 —— 它是真删除

client.reset() 就是 DELETE /meta/:type/:name(packages/data-objectstack/src/metadata-client.ts reset())。服务端 deleteMetaItem 对无基线条目:

  • SysMetadataRepository.deleteengine.delete('sys_metadata', { where: { id } }),硬删行,并写入 operation_type: 'delete' 墓碑历史;
  • intentruntime-only(artifactBacked 为假);
  • restoreArtifactRegistryView 的第三层(objectstack#5079 / PR #6687)在「下面没有任何一层能承接这个名字」时把 plain-key 注册项一并退役,所以列表/详情/派发三面一致地不再供应它;
  • 最有力的一条:服务端自己的回执就这么说(objectstack#5927 把四句回执按 artifactBacked 拆开)——
    • artifact-backed:Customization overlay deleted — … reset to artifact default.
    • 无基线:Deleted … — it no longer exists.

也就是说,服务端早已把这件事当作删除;唯一还在说「重置」的是客户端。

② 服务端对 sys_metadata 哨兵人群下发的 resettable —— 答 false

resolveLockStateresettable = artifactBacked(packages/spec/src/kernel/metadata-protection.zod.ts),而 isArtifactBackedlookupArtifactItemSchemaRegistry.getArtifactItem,其 isCodeArtifactBody_packageId === 'sys_metadata'_provenance === 'org'(isTenantAuthored)一律返回 false(packages/objectql/src/registry.ts)。framework 侧已有钉子:protocol-registry-shadow.test.ts 的 “getArtifactItem returns undefined for runtime-only and sys_metadata-sentinel items”。

结论:服务端答 resettable: false,是对的。缺陷全在客户端两份重导出的判定上 —— 不触发 fork report,不归 framework 仓,前提成立。

改动

packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx

  • 新增单一 verdict isResetSemantic,渲染侧(图标 / title)、确认框文案、执行分支三者同源;废掉 doReset() 里的 itemIsArtifact = layered?.code != null
  • 图标 RotateCcw / Trash2titleengine.edit.reset / engine.edit.delete、确认框的 engine.edit.resetConfirm / engine.edit.deleteConfirm、导航行为(删除 → navigate('../') 回列表;重置 → 重拉 layered 留在本页)全部由同一个布尔决定。

三态读的取舍(behavior change,写明)

resettableboolean | undefined,原来的 layered?.resettable !== false 把「没意见」并进了「true」——即向用户承诺一个可能并不存在的基线。现在:

读数 语义 行为
true 有包基线 重置覆盖层,留在本页重拉 layered
false 无基线可回退 删除,回列表
undefined 服务端没意见(ADR-0010 之前的信封) 回落到本页自己的保守分层 isArtifactItem

undefined 这一档不替服务端猜:回落值恰好就是这次改动之前渲染侧已经在用的那一个,所以老服务端保持原有渲染,而渲染与执行仍然读同一个值 —— 这正是本卡要的性质。用 ?? 而非 ||:服务端给的 false一个答案,不能掉进客户端启发式。

另一处 behavior change:按钮的锁闸从 isArtifactItem ? lockResettable : lockDeletable 改为两个动词统一用 lockDeletable。理由是契约面的:重置与删除是同一个请求,服务端只在一处闸它(assertLockAllowsDeleteevaluateLockForDelete),而 resettable 根本不是权限位,它是 artifactBacked。于是 _lock: 'no-delete' 的 artifact 条目不再渲染一颗服务端必然回 403 ITEM_LOCKED 的重置按钮。渲染一颗注定被拒的按钮,正是本卡要清掉的「契约的第二份副本」。

测试

新增 ResourceEditPage.resetVerdict.test.tsx(6 例)。每一例都同时断言三个面 —— 图标 + title、确认框文案、真正走到的分支(用真实 Routes + useLocation 探针观察导航,而非 mock navigate)。只断言其中一面正是这个缺陷能活下来的原因:每一面单独看都自洽,只有三者放在一起才矛盾。

覆盖:resettable:false 哨兵条目 → 删除三面一致;resettable:true → 重置三面一致;缺省 + 包条目 → 保守回落为重置;缺省 + 哨兵条目 → 「没意见」不被并进 true;provenance:'org' 但服务端答 resettable:true → verdict 压过客户端分层(证明客户端不再自己拿主意);deletable:false → 两个动词都不渲染。

pnpm exec vitest run packages/app-shell/src/views/metadata-admin/ --maxWorkers=2
 Test Files  173 passed (173)
      Tests  1720 passed | 1 skipped (1721)

pnpm exec turbo run type-check --concurrency=2
 Tasks:    81 successful, 81 total

node scripts/check-control-bytes.mjs
 OK (scanned 4386 tracked text file(s); skipped 85 binary).

反向验证(先预判,后跑)

预判(变异前记录): 把执行侧翻回手搓判定 itemIsArtifact = layered?.code != null、渲染侧保留 verdict → 对两个 code 层为 sys_metadata 哨兵(非空 code、删除语义)的 fixture,确认框文案退回 resetConfirm、导航不发生 → 6 例中恰好 2 例红(第 1、4 例),另外三个重置语义例与锁闸例保持绿。方向:红。

实测:

× resettable:false — renders Delete, ASKS delete, and leaves the page
× no verdict + org-own code layer — "no opinion" is NOT collapsed into resettable
 Tests  2 failed | 4 passed (6)

与预判逐例一致。还原用 git checkout(⛔ 未用 stash)。

相邻观察(已核,无需立卡)

读 framework 代码时注意到「runtime-only 条目删除后注册表残留 → 列表仍列出」这一路径,搜索既有 issue 后确认已由 objectstack#5079 / PR #6687 修复(restoreArtifactRegistryView 第三层),回执文案也已由 objectstack#5927 拆开。故不重复立卡。


Generated by Claude Code

… from the server's verdict (#4886)

One control, two independently hand-rolled artifact predicates. The render side
asked the page's two-tier `isArtifactItem` (which excludes the `sys_metadata`
save-path sentinel and ADR-0010 `provenance: 'org'`); `doReset()` asked a looser
one of its own, `layered?.code != null`. For a published org-own entry those
disagree — that entry's `code` layer IS its own rehydrated `sys_metadata` row —
so the button drew a trash can titled "Delete", asked "Reset overlay for …?",
and took the reset branch, leaving the operator on a page for an entry the
request had just destroyed.

Both sides now read ONE value, the one the server already computes and ships on
the layered envelope (`resolveLockState`: `resettable = artifactBacked`). Icon,
`title`, confirm text and branch can no longer disagree about the same entry.

Read as an honest tri-state: `undefined` means the server has no opinion
(pre-ADR-0010 envelope), and instead of the old `layered?.resettable !== false`
collapse into "resettable" the page falls back to its own conservative tier —
the same value the render side already used, so a legacy server keeps its legacy
rendering and both sides still move together. The button's lock gate is now
`deletable` for both verbs: reset and delete are the same request and the server
gates it once, through `evaluateLockForDelete`.

Both dispatch preconditions measured against the framework's `origin/main`
before implementing: a no-baseline DELETE hard-deletes the `sys_metadata` row
and the server's own receipt calls it "it no longer exists"; and for the
sentinel population the server answers `resettable: false`, so the defect was
client-side only.

Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the tests label Aug 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-CXxZQTDa.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 498.37KB 111.05KB
core (index.js) 3.79KB 1.52KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 157.05KB 43.28KB
fields (index.js) 231.73KB 57.60KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.86KB 10.83KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.75KB 18.37KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 130.85KB 33.44KB
plugin-designer (index.js) 212.39KB 42.83KB
plugin-detail (index.js) 239.81KB 59.97KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 120.43KB 29.04KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 197.59KB 53.01KB
plugin-kanban (index.js) 52.72KB 14.54KB
plugin-list (index.js) 111.23KB 26.97KB
plugin-map (index.js) 17.91KB 5.72KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.70KB 11.19KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 83.81KB 20.49KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 27.53KB 9.41KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.28KB 0.68KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

PM 验收:ACCEPT(#4886,批次 16,PM 会话 session_01GTRjn8xBqp75dk7kFupVRt)

前提测量(裁定附带的两项前置,dev 均按 premise-first 完成)

  • client.reset() 对无基线条目 = 同一 DELETE /meta/:type/:name:硬删行 + delete 墓碑 + 注册表条目退休;服务端回执自己就按 artifactBacked 分叉(「reset to artifact default」vs「it no longer exists」)—— 服务端早已把它当删除,只有客户端还在说重置。A 案语义被服务端自身证实。
  • ② 服务端对 sys_metadata 哨兵人群答 resettable: false(isCodeArtifactBody 对哨兵与 org-provenance 均 false,framework 侧已有 pin)—— 缺陷确属客户端,fork 条款不触发。
  • 测量纪律加分:dev 首轮误读了容器内落后 ~1700 号的共享 objectstack 检出,自查后按 git show origin/main: 重做两项测量 —— 正是「核验 main 用 origin/main」条款的教科书执行。

实物核验(已过):3 文件 +423/−14 与报告逐字对账;单一 isResetSemantic verdict 同源驱动图标/title/确认文案/执行分支/导航;?? 三态(false 是答案不得穿透到客户端启发式)、undefined 回落保守层;两份手搓判定(itemIsArtifactlockResettable)全部删除。标识 0;releases 0;边界零触碰。

有据偏离(接受):锁门从 isArtifactItem ? lockResettable : lockDeletable 收敛为双动词共用 lockDeletable —— 同一接缝:reset 与 delete 是同一个 DELETE,服务端经 evaluateLockForDelete 只把一次门;resettable 是 artifactBacked 不是权限,把它当权限正是本卡要拆的混淆。公开论证 + 测试 6 钉住。

反向验证:变异前书面预判「恰好 2/6 红(哨兵人群两例),红点形状指名」—— 实跑逐字命中(2 failed/4 passed,失败用例与预判一致);先 commit 后变异,git checkout 还原。测试 5 另钉服务端 verdict 压过客户端层级 —— 客户端确已不再自行裁决。每个用例三面同断言(图标+文案+执行),正中本卡「各面自洽、三面失谐」的缺陷形状。

CI(亲读终态):20 项 check runs 全 completed,18 success + 2 skipped,零失败。

附注:相邻路径(runtime-only 删除残留注册表条目)查重后确认已被 objectstack#5079/PR#6687 修复,未立重复卡 —— 立卡前查重纪律执行到位。

→ undraft + auto-merge (SQUASH)。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 17, 2026 03:52
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 17, 2026
Merged via the queue into main with commit 0a3ab5e Aug 17, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4886-lock-verdict branch August 17, 2026 03:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants