fix(metadata-protocol): the ADR-0010 lock gate must not fail open (#5706) - #5736
Merged
Merged
Conversation
) `getEffectiveLock` is the only source of truth for the ADR-0010 §3.3 lock gate, and both callers are write-path admission — `assertLockAllowsWrite` (save/publish/rollback) and `assertLockAllowsDelete`. Its overlay read was wrapped in a bare `catch` that fell through to `lock: 'none'`. `'none'` is not a neutral placeholder there: it is the verdict "the author declared no protection", which `evaluateLockForWrite` / `evaluateLockForDelete` turn straight into "allow". A `sys_metadata` read that FAILED therefore became a write that was PERFORMED on an item whose overlay row declared it protected. Measured on origin/main, with the row carrying `_lock` and only the gate's own read rejecting: `saveMetaItem` resolved `success: true` after `update:sys_metadata` on a `no-overlay` item, and `deleteMetaItem` the same on a `no-delete` one — while the same rows read successfully produce 403 ITEM_LOCKED. The audit trail did not compensate: the allowed path writes its ordinary `outcome: 'allowed'` row. Reuses `rethrowUnlessMetadataStoreUnprovisioned` (#5705) rather than inventing a second predicate — an unprovisioned `sys_metadata` genuinely has no overlay row, so `'none'` is the truth and first boot still saves; every other error becomes 503/SERVICE_UNAVAILABLE with the driver error as `cause`. Wire-visible, and deliberate: refusing one uncertain write beats performing one that had to be refused. Unaffected and pinned by regression tests: artifact-level locks (answered from the in-memory registry before the overlay read), a genuine miss on a healthy store, and control-plane kernels. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 6, 2026 03:20
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5706
缺陷
getEffectiveLock是 ADR-0010 §3.3 锁闸门的唯一判据来源,两个调用点都是写路径准入 ——assertLockAllowsWrite(save / publish / rollback)与assertLockAllowsDelete。它读 overlay 行的那段包在一个裸catch里,失败就落到lock: 'none'。'none'在这里不是中性缺省值,而是一条判定:「作者没有声明保护」。evaluateLockForWrite/evaluateLockForDelete直接把它翻译成「放行」。于是一次读失败变成一次已执行的写,而那一项的 overlay 行明明声明了保护。前提复核(在 origin/main 上实测,不是照抄 issue)
裸 catch 仍在,
rethrowUnlessMetadataStoreUnprovisioned(#5705)已可复用,fail-open 可复现。关键是把窗口建模对:issue 说的窗口不是「元数据库全挂」(全挂的话写本身也会失败),而是「读失败但写成功」。所以 harness 让第一条sys_metadata读(正是闸门自己那条)以ECONNREFUSED失败,其后的读写全部正常。修复前实测:saveMetaItem,overlay 行_lock: 'no-overlay',闸门读失败success: true,且update:sys_metadata真的执行了deleteMetaItem,overlay 行_lock: 'no-delete',闸门读失败success: true,且delete:sys_metadata真的执行了403 ITEM_LOCKED(闸门本身是好的)审计侧也如实核过:放行路径写的是它本来就会写的
outcome: 'allowed'行,所以事后没有任何记录显示这次写本该被拒 —— 这正是它长期不可见的原因。修法
按 issue 建议,一处 catch,复用 #5705 刚落在同文件的
rethrowUnlessMetadataStoreUnprovisioned,没有发明第二个判别:未建表(isMissingTableError)是良性的 —— 那时确实没有 overlay 行,'none'就是真相,首次启动照常;其余一律503/SERVICE_UNAVAILABLE,驱动错误挂cause。assertLockAllowsWrite/assertLockAllowsDelete的判定逻辑未改动 —— 它们拿到 503 自然上抛。这一点是用测试钉住的,不是假设。wire 可见变化(changeset 已写明):元数据存储读故障时,save / publish / rollback / delete 以 503 失败,而不是当作没锁去写。方向是刻意的:拒绝一次不确定的写,好过放行一次本该被拒的写。
反向验证(方向在跑之前就先定好)
普通的红。把裸
catch放回去 —— 实测 5 红 / 7 绿,五条全部以 issue 描述的那个形状失败,断言消息里直接印出放行的返回值:预测的是 4(第一个 describe 里那四条);第五条是 artifact describe 的最后一例,它本身就是一条 fail-closed 断言,只是为了叙事放在那里。按实测记录,没有凑成预测值。
保持绿的七条是有原因地绿,不是空绿:artifact 级锁根本走不到 overlay 读、未建表的首次启动照常保存、健康库上的真 miss 照常放行 —— 少了最后这条,「fail closed」可以靠「什么都拒」来假装满足。
issue 的「未验证点」:还有没有别的路径读同一行锁状态
枚举了保护信封的全部消费者(
extractProtection/resolveLockState,全仓仅protocol.ts内四处):resolveLockState@getMetaItem—— 不同病。它消费的是已经取到的 item,而那条读正是 fix(metadata-protocol): 元数据存储读不到不再被讲成「这一项不存在」 (#5532) #5705 修好的四处之一,已经 fail-closed;resolveLockState只会看到真读出来的东西。resolveLockState@getMetaItemLayered—— 同一个 catch 形状,但消费方是读/展示(Studio 三层对比视图),后果类别不同。已由 getMetaItemLayered 的 overlay 读用裸 catch:sys_metadata 读失败时三层视图把「读不到」画成「没有 overlay」 #5707 单独持有(finding标签),且那单里还留着一个未定的设计选择(整体 503,还是给overlay加「未知」第三态)。按 scope 纪律不在本 PR 修,也不重复开单。验证
前台同步跑完,全部真实输出:
pnpm --filter @objectstack/metadata-protocol testpnpm --filter @objectstack/objectql testpnpm --filter @objectstack/rest testpnpm --filter @objectstack/runtime testpnpm check:durability-log-levelpnpm check:query-options-erasuree6db317pnpm check:type-check-coveragetypecheckscript,带 28 错的实测 DEBT 条目)node scripts/check-nul-bytes.mjs+ 控制字符自查 grepeslint(改动的两个文件)objectql / rest / runtime 是按闸门的消费半径扫的,不是按改动包扫的。头一轮它们的「失败」全是新 worktree 里依赖没构建导致的解析错(0 条测试失败),按 AGENTS.md §9 先用
pnpm --filter的^...形式构建依赖再跑,才是上面的数字。范围
packages/metadata-protocol/src/protocol.ts仅getEffectiveLock一区(一处 catch + 其 JSDoc)+ 新测试 + changeset。没有碰 #5705 刚落的四处 overlay 读与getMetaItemCached,没有碰 rest / metadata,没有扩审计面。(顺带记一个实测事实,不是缺陷:
getEffectiveLock的 artifact 分支对任何非'none'的打包锁都会短路,不管那把锁是否拦得住当前这个操作 —— 这与「artifact 永远压过 overlay」的既有设计一致,测试里按实测钉住了,没有按我原先的猜测去写。)🤖 Generated with Claude Code
https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx