fix(app-shell): 重置/删除按钮渲染它真正执行的动词 —— 渲染侧与执行侧同读服务端 verdict (#4886) - #4903
Conversation
… 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>
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
|
PM 验收:ACCEPT(#4886,批次 16,PM 会话 前提测量(裁定附带的两项前置,dev 均按 premise-first 完成)
实物核验(已过):3 文件 +423/−14 与报告逐字对账;单一 有据偏离(接受):锁门从 反向验证:变异前书面预判「恰好 2/6 红(哨兵人群两例),红点形状指名」—— 实跑逐字命中(2 failed/4 passed,失败用例与预判一致);先 commit 后变异, CI(亲读终态):20 项 check runs 全 completed,18 success + 2 skipped,零失败。 附注:相邻路径(runtime-only 删除残留注册表条目)查重后确认已被 objectstack#5079/PR#6687 修复,未立重复卡 —— 立卡前查重纪律执行到位。 → undraft + auto-merge (SQUASH)。 Generated by Claude Code |
Fixes #4886
按维护者 2026-08-17 裁定实施 A 案(渲染语义胜出,经服务端 verdict 实现)。
缺陷
一颗按钮,两份各自手搓的 artifact 判定。渲染侧问页面的两级判定
isArtifactItem(排除sys_metadata保存路径哨兵与 ADR-0010provenance: '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.tsreset())。服务端deleteMetaItem对无基线条目:SysMetadataRepository.delete→engine.delete('sys_metadata', { where: { id } }),硬删行,并写入operation_type: 'delete'墓碑历史;intent取runtime-only(artifactBacked为假);restoreArtifactRegistryView的第三层(objectstack#5079 / PR #6687)在「下面没有任何一层能承接这个名字」时把 plain-key 注册项一并退役,所以列表/详情/派发三面一致地不再供应它;artifactBacked拆开)——Customization overlay deleted — … reset to artifact default.Deleted … — it no longer exists.也就是说,服务端早已把这件事当作删除;唯一还在说「重置」的是客户端。
② 服务端对
sys_metadata哨兵人群下发的resettable—— 答falseresolveLockState里resettable = artifactBacked(packages/spec/src/kernel/metadata-protection.zod.ts),而isArtifactBacked→lookupArtifactItem→SchemaRegistry.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.tsxisResetSemantic,渲染侧(图标 /title)、确认框文案、执行分支三者同源;废掉doReset()里的itemIsArtifact = layered?.code != null。RotateCcw/Trash2、title的engine.edit.reset/engine.edit.delete、确认框的engine.edit.resetConfirm/engine.edit.deleteConfirm、导航行为(删除 →navigate('../')回列表;重置 → 重拉 layered 留在本页)全部由同一个布尔决定。三态读的取舍(behavior change,写明)
resettable是boolean | undefined,原来的layered?.resettable !== false把「没意见」并进了「true」——即向用户承诺一个可能并不存在的基线。现在:truefalseundefinedisArtifactItemundefined这一档不替服务端猜:回落值恰好就是这次改动之前渲染侧已经在用的那一个,所以老服务端保持原有渲染,而渲染与执行仍然读同一个值 —— 这正是本卡要的性质。用??而非||:服务端给的false是一个答案,不能掉进客户端启发式。另一处 behavior change:按钮的锁闸从
isArtifactItem ? lockResettable : lockDeletable改为两个动词统一用lockDeletable。理由是契约面的:重置与删除是同一个请求,服务端只在一处闸它(assertLockAllowsDelete→evaluateLockForDelete),而resettable根本不是权限位,它是artifactBacked。于是_lock: 'no-delete'的 artifact 条目不再渲染一颗服务端必然回403 ITEM_LOCKED的重置按钮。渲染一颗注定被拒的按钮,正是本卡要清掉的「契约的第二份副本」。测试
新增
ResourceEditPage.resetVerdict.test.tsx(6 例)。每一例都同时断言三个面 —— 图标 +title、确认框文案、真正走到的分支(用真实Routes+useLocation探针观察导航,而非 mocknavigate)。只断言其中一面正是这个缺陷能活下来的原因:每一面单独看都自洽,只有三者放在一起才矛盾。覆盖:
resettable:false哨兵条目 → 删除三面一致;resettable:true→ 重置三面一致;缺省 + 包条目 → 保守回落为重置;缺省 + 哨兵条目 → 「没意见」不被并进 true;provenance:'org'但服务端答resettable:true→ verdict 压过客户端分层(证明客户端不再自己拿主意);deletable:false→ 两个动词都不渲染。反向验证(先预判,后跑)
预判(变异前记录): 把执行侧翻回手搓判定
itemIsArtifact = layered?.code != null、渲染侧保留 verdict → 对两个code层为sys_metadata哨兵(非空code、删除语义)的 fixture,确认框文案退回resetConfirm、导航不发生 → 6 例中恰好 2 例红(第 1、4 例),另外三个重置语义例与锁闸例保持绿。方向:红。实测:
与预判逐例一致。还原用
git checkout(⛔ 未用 stash)。相邻观察(已核,无需立卡)
读 framework 代码时注意到「runtime-only 条目删除后注册表残留 → 列表仍列出」这一路径,搜索既有 issue 后确认已由 objectstack#5079 / PR #6687 修复(
restoreArtifactRegistryView第三层),回执文案也已由 objectstack#5927 拆开。故不重复立卡。Generated by Claude Code