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

fix(metadata-protocol): 对象 overlay 写路径的 ownership 键切真实 package id + 服务端强制 provenance 盖章 (#4636 裁 B 第一步)#6219
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 的两个调用点(saveMetaItemrunPublishSideEffects)本来就带着 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: falsesaveMetaItem 的 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 一个字都没动)。restoreVersionput 时不传 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 这一片永远只有裸键:applyObjectRegistryMutationregisterItem(type, item, 'name') 不带 packageId(裸键),而全仓没有任何路径以 packageId 调 registerItem('object', …) —— plugin.ts:1908type === '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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 7, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review August 7, 2026 11:20
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit c9bf940 Aug 7, 2026
24 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-4636-writepath-real-package-id branch August 7, 2026 11:36
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