Skip to content

Commit c9bf940

Browse files
baozhoutaoclaude
andauthored
fix(metadata-protocol): 对象 overlay 写路径的 ownership 键切真实 package id + 服务端强制 provenance 盖章 (#4636 裁 B 第一步) (#6219)
* fix(metadata-protocol): 对象 overlay 写路径的 ownership 键切真实 package id + 服务端强制 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 * test(objectql): tripwire 注释指向已立单的 #6215 回滚的 package-bound 分支为何今天不可达,原本只写在 PR 正文里;把 issue 号写进 测试本体,读到这条 tripwire 的人不必回翻 PR 就知道它钉的是哪个缺陷。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2a61116 commit c9bf940

4 files changed

Lines changed: 525 additions & 7 deletions

File tree

.changeset/wide-donkeys-repeat.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
fix(metadata-protocol): 对象 overlay 写路径按真实 package id 记录 registry 归属,并由服务端强制盖 `_provenance: 'org'`
7+
8+
`applyObjectRegistryMutation` 此前把每一次对象写入都硬编码登记在 `'sys_metadata'` 哨兵下。
9+
该归属键同时就是包过滤键(`SchemaRegistry.getAllObjects(packageId)` 匹配的是
10+
`contributor.packageId`),因此通过 Studio 包工作区新建的对象,在自己所属包的过滤结果里
11+
一直是空的,直到有别的路径重新登记它。现在改为使用该行真实的 `package_id`;哨兵只保留
12+
给「没有绑定任何包」的写入,`rollbackMetaItem` 则从行本身读出绑定(而不是从请求读)。
13+
14+
同一次改动里,服务端在**副本**上无条件盖 `_provenance: 'org'`,不再采信请求体里的值:
15+
只搬归属键而不盖章会立刻复活 cloud#970 —— `applyProtection` 会把带包 id 且自身没有
16+
provenance 的 body 默认标成 `'package'`,`getArtifactItem` 据此认定它是代码制品,
17+
`object` 又声明了 `allowOrgOverride: false`,于是用户刚建好的对象在下一次保存时收到
18+
`403 not_overridable``metadata-read-decorations.ts` 有意不剥离 `_provenance`,
19+
Studio 的 GET → PUT 往返会把它原样送回,所以这个事实必须由服务端陈述。

packages/metadata-protocol/src/protocol.ts

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7354,12 +7354,61 @@ export class ObjectStackProtocolImplementation implements
73547354
* AFTER `put()` resolves successfully, so a failed write — DB error,
73557355
* optimistic-lock conflict, validation failure — never leaks a
73567356
* stale schema into the registry.
7357+
*
7358+
* ── OWNERSHIP KEY (#4636, maintainer ruling 2026-08-07, option B) ──
7359+
*
7360+
* The contributor is keyed by the row's REAL `package_id`, not by the
7361+
* `'sys_metadata'` sentinel this used to hard-write. The sentinel
7362+
* survives for exactly one case — a package-less write, which has no
7363+
* real id to use.
7364+
*
7365+
* Why the key had to move (and not the other side): the ownership key
7366+
* IS the package-filter key. `SchemaRegistry.getAllObjects(packageId)`
7367+
* matches `contributor.packageId`, so an object created through
7368+
* Studio's package workspace was invisible to its own package's filter
7369+
* until something re-registered it. The written contract in
7370+
* `objectql/src/registry.ts` (the `isTenantAuthored` header) already
7371+
* said the real id is the key and the sentinel is a save-path-only
7372+
* artefact; this makes the save path say the same thing.
7373+
*
7374+
* ── `_provenance: 'org'` IS STAMPED HERE, SERVER-SIDE ──
7375+
*
7376+
* On a COPY of the body, unconditionally — the request's own
7377+
* `_provenance` is never consulted, and never wins.
7378+
*
7379+
* That is load-bearing, not defensive coding. `applyProtection` (spec)
7380+
* stamps `_provenance: 'package'` whenever it is handed a package id
7381+
* and the body has not already answered, and the registry's artifact
7382+
* lookup reads exactly that key: a row registered under `app.<slug>`
7383+
* with package provenance IS a code artifact as far as
7384+
* `getArtifactItem` is concerned, so `isArtifactBacked` turns true and
7385+
* `saveMetaItem`'s overlay gate refuses the NEXT write to it with
7386+
* `not_overridable` — `object` declares `allowOrgOverride: false`.
7387+
* Moving the key without the stamp therefore re-creates cloud#970 (an
7388+
* app the user just built becomes silently un-editable) on the write
7389+
* path, one save later instead of one restart later. Measured, not
7390+
* assumed: see the reverse-verification limb in
7391+
* `objectql/src/protocol-writepath-object-ownership.test.ts`.
7392+
*
7393+
* Client-supplied provenance cannot be trusted here:
7394+
* `metadata-read-decorations.ts` deliberately does NOT strip
7395+
* `_provenance`, so a Studio GET → PUT round-trip echoes whatever the
7396+
* served document carried. Every row this method sees came out of a
7397+
* `sys_metadata` write, which is tenant-authored by definition
7398+
* (ADR-0010 `_provenance: 'org'`) — so the server states that fact
7399+
* rather than reading it back from the caller. Same sentence the boot
7400+
* re-hydration already writes for the same rows.
73577401
*/
7358-
private applyObjectRegistryMutation(request: { type: string; name: string; item?: any }): void {
7402+
private applyObjectRegistryMutation(request: { type: string; name: string; item?: any; packageId?: string | null }): void {
73597403
if (request.type !== 'object' && request.type !== 'objects') return;
73607404
this.engine.registry.registerItem(request.type, request.item, 'name');
73617405
try {
7362-
this.engine.registry.registerObject(request.item as any, 'sys_metadata');
7406+
this.engine.registry.registerObject(
7407+
{ ...(request.item as Record<string, unknown>), _provenance: 'org' } as any,
7408+
// `||`, not `??`: an empty-string binding is "no package", the
7409+
// same normalisation the boot branch applies to `package_id`.
7410+
request.packageId || 'sys_metadata',
7411+
);
73637412
} catch (err: any) {
73647413
console.warn(
73657414
`[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`,
@@ -7526,6 +7575,43 @@ export class ObjectStackProtocolImplementation implements
75267575
}
75277576
}
75287577

7578+
/**
7579+
* [#4636] The package binding of a persisted overlay row, read from the
7580+
* row itself.
7581+
*
7582+
* The write paths that HAVE a `packageId` parameter (`saveMetaItem`, the
7583+
* publish promotion) pass the caller's binding straight through — the same
7584+
* value `SysMetadataRepository.put` stamps on the row, so key and row agree
7585+
* by construction. `rollbackMetaItem` has no such parameter: it addresses a
7586+
* row that already exists, and the row's own `package_id` is the only
7587+
* authoritative answer to "who owns this".
7588+
*
7589+
* Mirrors the repository's own `whereFor(ref, 'active', undefined)`: no
7590+
* package predicate (match any package), scoped by org + type + name +
7591+
* state. Deliberately NOT a `findOne` on the repository — `MetadataItem`
7592+
* projects the body, not the binding, and widening that shared type to
7593+
* carry one field for one caller is a contract change PR1 does not need.
7594+
*
7595+
* Not caught: a metadata-store outage here means the ownership key would be
7596+
* a guess, and every caller reads this BEFORE its write, so failing is
7597+
* still failing closed.
7598+
*/
7599+
private async resolveOverlayPackageBinding(
7600+
type: string,
7601+
name: string,
7602+
organizationId: string | null,
7603+
): Promise<string | null> {
7604+
const row = await this.engine.findOne('sys_metadata', {
7605+
where: {
7606+
type,
7607+
name,
7608+
organization_id: organizationId,
7609+
state: 'active',
7610+
},
7611+
});
7612+
return (row as { package_id?: string | null } | null)?.package_id ?? null;
7613+
}
7614+
75297615
/**
75307616
* Inverse of {@link ensureObjectStorage}: drop an object's physical table.
75317617
* DESTRUCTIVE — deletes the table and all its rows. Only invoked when a
@@ -10137,6 +10223,19 @@ export class ObjectStackProtocolImplementation implements
1013710223
name: request.name,
1013810224
org: orgId ?? 'env',
1013910225
} as Parameters<typeof repo.restoreVersion>[0];
10226+
// [#4636] The ownership key the write-through below needs, read from
10227+
// the ROW rather than from the request — `rollbackMetaItem` has no
10228+
// `packageId` parameter, and inventing one would let a caller re-key
10229+
// an object it does not own. `restoreVersion` → `put` preserves an
10230+
// existing non-null `package_id` on update, so the binding read here
10231+
// is the binding the restored row still carries.
10232+
//
10233+
// Read BEFORE the restore, deliberately: the row exists at this point
10234+
// and a read failure can still fail the whole rollback cleanly. Reading
10235+
// it afterwards would put a fallible query downstream of a write that
10236+
// already succeeded — the shape that ends in a `catch {}` swallowing a
10237+
// real outage (#4867).
10238+
const rollbackPackageId = await this.resolveOverlayPackageBinding(singularType, request.name, orgId);
1014010239
try {
1014110240
const result = await repo.restoreVersion(ref, request.toVersion, {
1014210241
// #4556 — NULL, not 'system', for an actor-less rollback.
@@ -10148,10 +10247,17 @@ export class ObjectStackProtocolImplementation implements
1014810247
// #4521 — a rollback is a live write like any other: the restored
1014910248
// body must be the one the runtime dispatches on immediately, not
1015010249
// after someone lists the type.
10250+
// #4636 — …under the SAME ownership key `saveMetaItem` used. Left
10251+
// unpassed, an object row bound to `app.<slug>` re-registered here
10252+
// under the `'sys_metadata'` sentinel and `registerObject` threw
10253+
// `already owned by package "app.<slug>"` into the best-effort
10254+
// `console.warn` — a rollback that reported success while the
10255+
// registry kept serving the body it was supposed to revert.
1015110256
this.applyRegistryWriteThrough({
1015210257
type: singularType,
1015310258
name: request.name,
1015410259
item: result.item.body,
10260+
packageId: rollbackPackageId,
1015510261
});
1015610262
return {
1015710263
success: true,

0 commit comments

Comments
 (0)