Skip to content

fix(metadata-protocol): 对象 overlay 写路径的 ownership 键切真实 package id + 服务端强制 provenance 盖章 (#4636 裁 B 第一步) - #6219

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-4636-writepath-real-package-id
Aug 7, 2026
Merged

baozhoutao merged 3 commits into
mainfrom
claude/issue-4636-writepath-real-package-id

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Part of #4636 — 维护者 2026-08-07 裁决 B 的第一步:写路径半边。boot 半边(loadMetaFromDb)按裁决的次序论证留给 PR2,本 PR 不触;因此正文不写 Fixes,#4636 留给 PR2 关闭。

问题

applyObjectRegistryMutation 把每一次对象 overlay 写入都硬编码登记在 'sys_metadata' 哨兵下。这个归属键同时就是包过滤键 —— SchemaRegistry.getAllObjects(packageId) 匹配的是 contributor.packageId(registry.ts:1252),runtime meta.ts 的侧边栏包过滤直接消费它。于是通过 Studio 包工作区新建的对象,在它自己所属包的过滤结果里是空的,直到有别的路径把它重新登记一遍。

也正因为如此,boot 侧不能单独修:两侧一旦一个用 app.{slug}、一个用 'sys_metadata',registerObject 会在第二次认领时抛 already owned by package … —— 这正是前任 dev 实测出来、并让本单转维护者的那条死路。

改了什么

三处,都在写路径:

  1. applyObjectRegistryMutation 接受 packageId,归属键改为 request.packageId || 'sys_metadata'。哨兵只留给「没有绑定任何包」的写入。用 || 而不是 ??:空串绑定就是「没有包」,与 boot 分支对 package_id 的归一化写法一致。上游 applyRegistryWriteThrough 的两个调用点(saveMetaItem、runPublishSideEffects)本来就带着 packageId,只是没往下传。
  2. 服务端在副本上无条件盖 _provenance: 'org',不采信请求体。
  3. rollbackMetaItem 从行本身读出绑定(新增 resolveOverlayPackageBinding)再写透。rollbackMetaItem 请求上没有 packageId 参数,凭空加一个等于允许调用方重设自己并不拥有的对象的归属;行的 package_id 才是权威答案。读在 restore 之前做:此刻行还在,读失败可以干净地让整个回滚失败,而不是把一个会失败的查询摆在已经成功的写入后面([metadata-protocol] SysMetadataRepository 的 nextEventSeq()/nextItemVersion() 同样把读失败当「表还没建」,静默从 1 重新发号 —— #4825 在 canonical 路径上的同形缺陷 #4867 那种 catch {} 吞掉真实故障的形状)。

为什么盖章必须和切键同一个 PR

只切键会立刻复活 cloud#970。因果链是机械的:applyProtection(spec)拿到一个包 id、而 body 自己没有 _provenance 时,默认盖 'package' → getArtifactItem 读的正是这个键 → isArtifactBacked 变真 → 而 object 声明了 allowOrgOverride: false → saveMetaItem 的 overlay 门对下一次保存回 403 not_overridable。用户刚建好的 app 变成静默不可编辑,只是从「重启一次之后」提前到「保存一次之后」。

客户端的 provenance 在这里不可信:metadata-read-decorations.ts 有意不剥离 _provenance,Studio 的 GET → PUT 往返会把服务端自己发出去的值原样送回来 —— 采信它等于采信自己的输出。所以服务端陈述这个事实,而不是读回来。这与 boot 重水合对同一批行早就在写的那句话完全一致。

前提复核(在 origin/main 上)

前提 结论 证据
P1 applyObjectRegistryMutation 仍硬写 'sys_metadata' ✅ 成立 protocol.ts:7362(立单时 :7220,行号已漂移):registerObject(request.item as any, 'sys_metadata')
P2 B-minimal 陷阱仍成立 ✅ 成立,已重跑探针 见下「反向验证 · 肢 A」:真 SchemaRegistry + 真 protocol,只去掉盖章 → 403 not_overridable 当场复现
P3 registry.ts 契约注释的 save-path 半句 ⚠️ 被本 PR 改动波及,已就地最小修正 见下

P3 细节:注释原文「That sentinel only holds on the save path」在本 PR 之后失真 —— save path 现在只对「无包绑定」的写入用哨兵。已就地最小改写成「该哨兵只标记一件事:这行没有绑定任何包」,并把 boot 半句显式标注为「描述的是契约,还不是代码」(PR2 落地后即可去掉该标注)。注释的结论(_packageId !== 'sys_metadata' 无法回答 provenance)不但没变,而且更成立了:切键之后连 save path 的包绑定行也带真实 id,哨兵判据更不可靠。注释本体的完整同步仍归 PR2。

测试

新增 packages/objectql/src/protocol-writepath-object-ownership.test.ts(10 例)。放在 objectql 而不是被测代码旁边,是因为断言的对象是真 SchemaRegistry(ownership 冲突、applyProtection 的 provenance 默认、getAllObjects 过滤);objectql 依赖 metadata-protocol,只有这个方向能同时握住两半,反向 import 会闭合 turbo 拒绝的环。

  • 写路径归属:带 packageId → contributor 记 app.myapp;不带 → 哨兵照旧。
  • 服务端盖章:body 不带 _provenance → 记 'org';body 回传 _provenance: 'package' → 仍记 'org'(请求永不胜出);且盖在副本上,调用方 body 不被写回 _packageId / _provenance。
  • cloud#970 反例:新建 package-bound 对象 → 二次保存成功,due_date 进内存 schema,落盘仍是一行、仍绑在包上。
  • B-minimal 反例(同一个键、去掉盖章)→ 403 not_overridable,并断言 _provenance 被默认成 'package',把因果链本身钉住。
  • 包过滤面:getAllObjects('app.myapp') 首次保存即返回该对象,且不串到 app.otherapp。
  • 回滚:package-less 回滚走通(键从行读出 → 哨兵),恢复体生效。
pnpm --workspace-concurrency=2 --filter @objectstack/metadata-protocol --filter @objectstack/objectql test

packages/metadata-protocol test:  Test Files  49 passed (49)
packages/metadata-protocol test:       Tests  502 passed (502)
packages/objectql test:  Test Files  136 passed (136)
packages/objectql test:       Tests  2224 passed (2224)

门:

check-engine-double-contract: OK — 75 pinned, 133 in the DEBT ledger, 2 exempt.     (本 PR 前 73 pinned;新增的两个 double 已 pinned)
✓ durability-degradation log levels: 24 durability-critical catch seam(s), all loud, rethrowing or propagating to the caller
✓ read-seam invention (#5186, 3 package roots): 64 read seam(s), none invents an unreported empty answer
check-nul-bytes: OK (scanned 5920 tracked text file(s); no raw ASCII control bytes)
✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).
check-type-check-coverage: OK — 62/77 workspace packages type-checked

packages/objectql pnpm typecheck 通过。metadata-protocol 没有 typecheck 脚本(在 type-check-coverage 的 DEBT ledger 里),直接跑 tsc --noEmit 得到 63 条既存错误,全部在该包的 *.test.ts 里,src/protocol.ts 零错误 —— 基线状况,非本 PR 引入。

反向验证(方向先写死,再实测)

⚠️ 一个必须先说的测量条件:objectql 的测试通过 package exports 解析到 metadata-protocol 的 dist/,不是 src。第一次跑肢 A 时忘了重建,得到「全绿」的假阴性;两条肢的下列结果都是重建之后的。

肢 预测 实测 符合?
A 去掉强制盖章(B-minimal 形态) cloud#970 反例用例翻红 5 例红。核心红就是预测的那条:[not_overridable] Metadata item 'object/myapp_invoice' is provided by a code package …;另 expected 'package' to be 'org' ✅ 方向符合,红的范围比预测大
B packageId 管线断开(恢复硬写哨兵) 写路径用例翻红 2 例红:expected 'sys_metadata' to be 'app.myapp';包过滤 expected [] to deeply equal [ 'myapp_invoice' ] ✅ 完全符合

肢 A 多出来的 4 例不是预测落空,是同一个 403 的下游:盖章一去,第一次保存就把对象标成代码制品,于是两条 provenance 断言直接红,两条回滚用例连第二次保存都做不到(其中包括 tripwire 那条,它拿到的是 not_overridable 而不是预期的 metadata_conflict)。按「模板是工具不是真相」,如实记下范围差,而不是把它修剪成预测的样子。

另一处如实记录:肢 B 没有覆盖到回滚那条改动 —— package-bound 回滚在两种形态下都 409(见下),package-less 回滚两种形态下都走哨兵。回滚这一笔的正确性由「键从行读出而不是从请求读」的论证 + package-less 端到端用例承担,不由肢 B 承担。

途中发现,已立单不修:#6215

rollbackMetaItem / revertCommit 对任何 package-bound overlay 行必定 409,与本 PR 无关(sys-metadata-repository.ts 一个字都没动)。restoreVersion 调 put 时不传 packageId,put 又用 whereFor(ref, state, opts.packageId ?? null) —— null 是谓词(package_id IS NULL)而不是「任意包」,于是它找不到自己正在恢复的那一行,parent 读成 null:

[metadata_conflict] object/myapp_invoice advanced during rollback.
Expected parent sha256:00ca6e… but current is null.

后果:本 PR 回滚改动的 package-bound 分支今天不可达。我没有顺手修它(不在裁决范围,且改的是仓库写路径,需要自己的 pin 测试和 review),而是留了一条 tripwire 用例断言当前的 409 并指向 #6215 —— #6215 修好那天这条用例会红,修的人必须接着断言 ownership 键,而那句断言就写在它上一条用例里。

必答项

#5079(removeRuntimeShadow)重新定价 —— 重测结论:治愈条件语义没有变化,成本模型不变;#5079 不应被定价成「等 #4636」。

探针(真 SchemaRegistry,复现写路径改动后的两次调用,带对照组):

metadata.get("object") keys       : [ 'myapp_invoice' ]
objectContributors owner packageId: app.myapp
removeRuntimeShadow("object", …)  : false
removeRuntimeShadow("objects", …) : false
--- pre-#4636 (sentinel key) ---
objectContributors owner packageId: sys_metadata
removeRuntimeShadow("object", …)  : false
--- control: page (non-object) ---
metadata.get("page") keys         : [ 'com.acme.pkg:home', 'home' ]
removeRuntimeShadow("page", home) : true

机制:removeRuntimeShadow 扫的是 this.metadata.get(type) 里的复合键 pkg:name 兄弟项,判据 it._packageId !== 'sys_metadata' 也是对那个 Map 里的条目求值。而 object 这一片永远只有裸键:applyObjectRegistryMutation 调 registerItem(type, item, 'name') 不带 packageId(裸键),而全仓没有任何路径以 packageId 调 registerItem('object', …) —— plugin.ts:1908 对 type === 'object' 显式 return(注释:objects are registered differently (ownership model))。所以复合兄弟项从不存在,循环根本走不到那个判据。本 PR 搬的是 objectContributors 的键,是 removeRuntimeShadow 从不触碰的另一个结构。对照组证明探针有效:page 这种真的走 registerItem(..., packageId) 的类型两个键都在、true 治愈成功。

#5927(deleteMetaItem 回执,同文件)—— 否,未触其面。 本 PR 只碰 applyObjectRegistryMutation / applyRegistryWriteThrough 的调用点与 rollbackMetaItem;deleteMetaItem 及其回执构造、restoreArtifactRegistryView 一行未动。

#5488 / #5311(saveMetaItem 的 api 命名空间门,下轮)—— 定价不变,面不相交。 那两单要动的是 saveMetaItem 前半段的类型门/命名空间判定(environmentId !== undefined 那一块 gate),本 PR 的 packageId 管线全部落在持久化之后的 registry 写透(if (mode === 'publish') 之后)以及 rollbackMetaItem,两者之间隔着 repo.put。唯一的间接影响是正向的:写路径之后 isArtifactBacked('object', …) 对租户自建对象稳定为 false(靠强制盖章,而不是靠哨兵字符串),那两单要读这个判据时拿到的答案比现在更可靠。

PR2 的入场券(PR1 合入后 boot 半边还剩什么,精确到行为差异):

  • 唯一代码改动仍是 loadMetaFromDb object 分支那一行(现 protocol.ts:10657 一带):record.packageId || 'sys_metadata' → (record as { package_id?: string | null }).package_id || 'sys_metadata'。该分支已经在盖 _provenance: 'org',所以 PR2 不需要再处理盖章。
  • PR1 之后的行为差异收敛成一条:一个绑在 app.myapp 上的对象,本次会话内创建/编辑后归属键是 app.myapp(包过滤看得见),重启之后归属键退回 'sys_metadata'(包过滤又看不见了)。即分类 bug 仍在,但只在重启后出现,且不再有静默丢编辑的风险 —— 因为两侧都盖 'org',isArtifactBacked 在两种键下都是 false,cloud#970 不会因为键不一致而复活。这正是裁决所说「先修写路径,之后任意时点落 boot 都可发布」。
  • PR2 需要的 pin 测试:boot 水合一条带 package_id 的 object 行 → 断言 registry 归属是真实 id、_provenance 是 'org'、且紧接着的一次 saveMetaItem 成功(重启后可编辑,cloud#970 的重启面)。
  • PR2 同时收尾 objectql/src/registry.ts 那段契约注释:去掉本 PR 加的「描述的是契约,还不是代码」标注。
  • 不构成阻塞:PR2 与 rollbackMetaItem / revertCommit always 409 on a package-bound overlay row — restoreVersion's put() scopes the parent lookup to package_id IS NULL #6215 无依赖关系。

Generated by Claude Code

claude added 3 commits August 7, 2026 10:54
…务端强制 provenance 盖章

`applyObjectRegistryMutation` 此前把每一次对象写入都硬编码登记在 `'sys_metadata'`
哨兵下。该归属键同时就是包过滤键(`getAllObjects(packageId)` 匹配的是
`contributor.packageId`),所以通过 Studio 包工作区新建的对象在自己所属包的过滤结果
里一直是空的;boot 侧(PR2)也无法单独改成真实 id —— 两侧会为同一个对象互相抢
ownership,`registerObject` 在第二次认领时抛 `already owned by package …`。

改动三处:
- `applyObjectRegistryMutation` 接受 `packageId`,用 `request.packageId ||
  'sys_metadata'` 作归属键(哨兵只留给「没有绑定包」的写入);
- 服务端在**副本**上无条件盖 `_provenance: 'org'`,不采信请求体;
- `rollbackMetaItem` 从行本身(`resolveOverlayPackageBinding`)读出绑定后再写透,
  而不是从请求读 —— 请求上没有这个参数,凭空加一个等于允许调用方重设自己不拥有的
  对象的归属。

盖章与切键必须同一个 PR:只切键会立刻复活 cloud#970。`applyProtection` 会把带包 id
且自身没有 provenance 的 body 默认标成 `'package'`,`getArtifactItem` 据此判定它是
代码制品,而 `object` 声明了 `allowOrgOverride: false`,于是用户刚建好的对象在下一次
保存时收到 403 not_overridable。新增测试里的 B-minimal 反例在真 SchemaRegistry 上把
这条因果链钉住了。

Part of #4636

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
回滚的 package-bound 分支为何今天不可达,原本只写在 PR 正文里;把 issue 号写进
测试本体,读到这条 tripwire 的人不必回翻 PR 就知道它钉的是哪个缺陷。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@vercel

vercel Bot commented Aug 7, 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 7, 2026 11:05am

Request Review

@github-actions github-actions Bot added the size/l label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/objectql.

15 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, @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol, @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)
  • 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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants