Skip to content

fix(metadata-protocol): the ADR-0010 lock gate must not fail open (#5706) - #5736

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5706-lock-gate-fail-closed
Aug 6, 2026
Merged

fix(metadata-protocol): the ADR-0010 lock gate must not fail open (#5706)#5736
os-zhuang merged 1 commit into
mainfrom
claude/issue-5706-lock-gate-fail-closed

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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',闸门读失败RESOLVED success: true,且 update:sys_metadata 真的执行了
deleteMetaItem,overlay 行 _lock: 'no-delete',闸门读失败RESOLVED 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 描述的那个形状失败,断言消息里直接印出放行的返回值:

expected a rejection, but the call resolved with
{"success":true,…,"message":"Saved customization overlay (env-wide, …)"}
{"success":true,"reset":true,…,"message":"Customization overlay deleted — view/v1 …"}

预测的是 4(第一个 describe 里那四条);第五条是 artifact describe 的最后一例,它本身就是一条 fail-closed 断言,只是为了叙事放在那里。按实测记录,没有凑成预测值。

保持绿的七条是有原因地绿,不是空绿:artifact 级锁根本走不到 overlay 读、未建表的首次启动照常保存、健康库上的真 miss 照常放行 —— 少了最后这条,「fail closed」可以靠「什么都拒」来假装满足。

issue 的「未验证点」:还有没有别的路径读同一行锁状态

枚举了保护信封的全部消费者(extractProtection / resolveLockState,全仓仅 protocol.ts 内四处):

验证

前台同步跑完,全部真实输出:

命令结果
pnpm --filter @objectstack/metadata-protocol test46 files / 431 passed
pnpm --filter @objectstack/objectql test122 files / 1990 passed
pnpm --filter @objectstack/rest test53 files / 760 passed
pnpm --filter @objectstack/runtime test98 files / 1436 passed
pnpm check:durability-log-level✓ 24 seam(s), all loud/rethrowing
pnpm check:query-options-erasure✓ ratchet holds, baseline verified against e6db317
pnpm check:type-check-coverage✓ DEBT ledger unmoved(本包无 typecheck script,带 28 错的实测 DEBT 条目)
node scripts/check-nul-bytes.mjs + 控制字符自查 grep✓ clean
eslint(改动的两个文件)✓ 0

objectql / rest / runtime 是按闸门的消费半径扫的,不是按改动包扫的。头一轮它们的「失败」全是新 worktree 里依赖没构建导致的解析错(0 条测试失败),按 AGENTS.md §9 先用 pnpm --filter^... 形式构建依赖再跑,才是上面的数字。

范围

packages/metadata-protocol/src/protocol.tsgetEffectiveLock 一区(一处 catch + 其 JSDoc)+ 新测试 + changeset。没有碰 #5705 刚落的四处 overlay 读与 getMetaItemCached,没有碰 rest / metadata,没有扩审计面。

(顺带记一个实测事实,不是缺陷:getEffectiveLock 的 artifact 分支对任何'none' 的打包锁都会短路,不管那把锁是否拦得住当前这个操作 —— 这与「artifact 永远压过 overlay」的既有设计一致,测试里按实测钉住了,没有按我原先的猜测去写。)

🤖 Generated with Claude Code

https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx

)
`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
@vercel

vercelBot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 6, 2026 2:52am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/metadata-protocol)
  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

getEffectiveLock 的 overlay 读用裸 catch,sys_metadata 读失败时保护闸门 fail-open(_lock 落成 'none',写/删被放行)

2 participants

@os-zhuang@claude