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-zhuang os-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 test 46 files / 431 passed
pnpm --filter @objectstack/objectql test 122 files / 1990 passed
pnpm --filter @objectstack/rest test 53 files / 760 passed
pnpm --filter @objectstack/runtime test 98 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

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 6, 2026 2:52am

Request Review

@github-actions github-actions Bot added the size/m label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

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 free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants