From 80f94819b50e1486adbab4ee49ab46e468871622 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 10:54:09 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(metadata-protocol):=20=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1=20overlay=20=E5=86=99=E8=B7=AF=E5=BE=84=E7=9A=84=20ow?= =?UTF-8?q?nership=20=E9=94=AE=E5=88=87=E7=9C=9F=E5=AE=9E=20package=20id?= =?UTF-8?q?=20+=20=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=BC=BA=E5=88=B6=20provenan?= =?UTF-8?q?ce=20=E7=9B=96=E7=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 objectstack-ai/objectstack#4636 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We --- .changeset/wide-donkeys-repeat.md | 19 + packages/metadata-protocol/src/protocol.ts | 110 ++++- ...rotocol-writepath-object-ownership.test.ts | 390 ++++++++++++++++++ packages/objectql/src/registry.ts | 14 +- 4 files changed, 526 insertions(+), 7 deletions(-) create mode 100644 .changeset/wide-donkeys-repeat.md create mode 100644 packages/objectql/src/protocol-writepath-object-ownership.test.ts diff --git a/.changeset/wide-donkeys-repeat.md b/.changeset/wide-donkeys-repeat.md new file mode 100644 index 0000000000..3982ec3209 --- /dev/null +++ b/.changeset/wide-donkeys-repeat.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/objectql': patch +--- + +fix(metadata-protocol): 对象 overlay 写路径按真实 package id 记录 registry 归属,并由服务端强制盖 `_provenance: 'org'` + +`applyObjectRegistryMutation` 此前把每一次对象写入都硬编码登记在 `'sys_metadata'` 哨兵下。 +该归属键同时就是包过滤键(`SchemaRegistry.getAllObjects(packageId)` 匹配的是 +`contributor.packageId`),因此通过 Studio 包工作区新建的对象,在自己所属包的过滤结果里 +一直是空的,直到有别的路径重新登记它。现在改为使用该行真实的 `package_id`;哨兵只保留 +给「没有绑定任何包」的写入,`rollbackMetaItem` 则从行本身读出绑定(而不是从请求读)。 + +同一次改动里,服务端在**副本**上无条件盖 `_provenance: 'org'`,不再采信请求体里的值: +只搬归属键而不盖章会立刻复活 cloud#970 —— `applyProtection` 会把带包 id 且自身没有 +provenance 的 body 默认标成 `'package'`,`getArtifactItem` 据此认定它是代码制品, +`object` 又声明了 `allowOrgOverride: false`,于是用户刚建好的对象在下一次保存时收到 +`403 not_overridable`。`metadata-read-decorations.ts` 有意不剥离 `_provenance`, +Studio 的 GET → PUT 往返会把它原样送回,所以这个事实必须由服务端陈述。 diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a79a79c2d8..71404b01d7 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -7354,12 +7354,61 @@ export class ObjectStackProtocolImplementation implements * AFTER `put()` resolves successfully, so a failed write — DB error, * optimistic-lock conflict, validation failure — never leaks a * stale schema into the registry. + * + * ── OWNERSHIP KEY (#4636, maintainer ruling 2026-08-07, option B) ── + * + * The contributor is keyed by the row's REAL `package_id`, not by the + * `'sys_metadata'` sentinel this used to hard-write. The sentinel + * survives for exactly one case — a package-less write, which has no + * real id to use. + * + * Why the key had to move (and not the other side): the ownership key + * IS the package-filter key. `SchemaRegistry.getAllObjects(packageId)` + * matches `contributor.packageId`, so an object created through + * Studio's package workspace was invisible to its own package's filter + * until something re-registered it. The written contract in + * `objectql/src/registry.ts` (the `isTenantAuthored` header) already + * said the real id is the key and the sentinel is a save-path-only + * artefact; this makes the save path say the same thing. + * + * ── `_provenance: 'org'` IS STAMPED HERE, SERVER-SIDE ── + * + * On a COPY of the body, unconditionally — the request's own + * `_provenance` is never consulted, and never wins. + * + * That is load-bearing, not defensive coding. `applyProtection` (spec) + * stamps `_provenance: 'package'` whenever it is handed a package id + * and the body has not already answered, and the registry's artifact + * lookup reads exactly that key: a row registered under `app.` + * with package provenance IS a code artifact as far as + * `getArtifactItem` is concerned, so `isArtifactBacked` turns true and + * `saveMetaItem`'s overlay gate refuses the NEXT write to it with + * `not_overridable` — `object` declares `allowOrgOverride: false`. + * Moving the key without the stamp therefore re-creates cloud#970 (an + * app the user just built becomes silently un-editable) on the write + * path, one save later instead of one restart later. Measured, not + * assumed: see the reverse-verification limb in + * `objectql/src/protocol-writepath-object-ownership.test.ts`. + * + * Client-supplied provenance cannot be trusted here: + * `metadata-read-decorations.ts` deliberately does NOT strip + * `_provenance`, so a Studio GET → PUT round-trip echoes whatever the + * served document carried. Every row this method sees came out of a + * `sys_metadata` write, which is tenant-authored by definition + * (ADR-0010 `_provenance: 'org'`) — so the server states that fact + * rather than reading it back from the caller. Same sentence the boot + * re-hydration already writes for the same rows. */ - private applyObjectRegistryMutation(request: { type: string; name: string; item?: any }): void { + private applyObjectRegistryMutation(request: { type: string; name: string; item?: any; packageId?: string | null }): void { if (request.type !== 'object' && request.type !== 'objects') return; this.engine.registry.registerItem(request.type, request.item, 'name'); try { - this.engine.registry.registerObject(request.item as any, 'sys_metadata'); + this.engine.registry.registerObject( + { ...(request.item as Record), _provenance: 'org' } as any, + // `||`, not `??`: an empty-string binding is "no package", the + // same normalisation the boot branch applies to `package_id`. + request.packageId || 'sys_metadata', + ); } catch (err: any) { console.warn( `[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`, @@ -7526,6 +7575,43 @@ export class ObjectStackProtocolImplementation implements } } + /** + * [#4636] The package binding of a persisted overlay row, read from the + * row itself. + * + * The write paths that HAVE a `packageId` parameter (`saveMetaItem`, the + * publish promotion) pass the caller's binding straight through — the same + * value `SysMetadataRepository.put` stamps on the row, so key and row agree + * by construction. `rollbackMetaItem` has no such parameter: it addresses a + * row that already exists, and the row's own `package_id` is the only + * authoritative answer to "who owns this". + * + * Mirrors the repository's own `whereFor(ref, 'active', undefined)`: no + * package predicate (match any package), scoped by org + type + name + + * state. Deliberately NOT a `findOne` on the repository — `MetadataItem` + * projects the body, not the binding, and widening that shared type to + * carry one field for one caller is a contract change PR1 does not need. + * + * Not caught: a metadata-store outage here means the ownership key would be + * a guess, and every caller reads this BEFORE its write, so failing is + * still failing closed. + */ + private async resolveOverlayPackageBinding( + type: string, + name: string, + organizationId: string | null, + ): Promise { + const row = await this.engine.findOne('sys_metadata', { + where: { + type, + name, + organization_id: organizationId, + state: 'active', + }, + }); + return (row as { package_id?: string | null } | null)?.package_id ?? null; + } + /** * Inverse of {@link ensureObjectStorage}: drop an object's physical table. * DESTRUCTIVE — deletes the table and all its rows. Only invoked when a @@ -10137,6 +10223,19 @@ export class ObjectStackProtocolImplementation implements name: request.name, org: orgId ?? 'env', } as Parameters[0]; + // [#4636] The ownership key the write-through below needs, read from + // the ROW rather than from the request — `rollbackMetaItem` has no + // `packageId` parameter, and inventing one would let a caller re-key + // an object it does not own. `restoreVersion` → `put` preserves an + // existing non-null `package_id` on update, so the binding read here + // is the binding the restored row still carries. + // + // Read BEFORE the restore, deliberately: the row exists at this point + // and a read failure can still fail the whole rollback cleanly. Reading + // it afterwards would put a fallible query downstream of a write that + // already succeeded — the shape that ends in a `catch {}` swallowing a + // real outage (#4867). + const rollbackPackageId = await this.resolveOverlayPackageBinding(singularType, request.name, orgId); try { const result = await repo.restoreVersion(ref, request.toVersion, { // #4556 — NULL, not 'system', for an actor-less rollback. @@ -10148,10 +10247,17 @@ export class ObjectStackProtocolImplementation implements // #4521 — a rollback is a live write like any other: the restored // body must be the one the runtime dispatches on immediately, not // after someone lists the type. + // #4636 — …under the SAME ownership key `saveMetaItem` used. Left + // unpassed, an object row bound to `app.` re-registered here + // under the `'sys_metadata'` sentinel and `registerObject` threw + // `already owned by package "app."` into the best-effort + // `console.warn` — a rollback that reported success while the + // registry kept serving the body it was supposed to revert. this.applyRegistryWriteThrough({ type: singularType, name: request.name, item: result.item.body, + packageId: rollbackPackageId, }); return { success: true, diff --git a/packages/objectql/src/protocol-writepath-object-ownership.test.ts b/packages/objectql/src/protocol-writepath-object-ownership.test.ts new file mode 100644 index 0000000000..3c599d954e --- /dev/null +++ b/packages/objectql/src/protocol-writepath-object-ownership.test.ts @@ -0,0 +1,390 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4636 PR1 — the object-overlay WRITE path keys SchemaRegistry ownership by + * the row's REAL package id, and stamps `_provenance: 'org'` server-side. + * + * ## What was wrong + * + * `applyObjectRegistryMutation` hard-wrote `'sys_metadata'` as the ownership + * key for every object write. The ownership key IS the package-filter key + * (`getAllObjects(packageId)` matches `contributor.packageId`), so an object + * created through Studio's package workspace was invisible to its own + * package's filter, and the boot re-hydration (#4636 PR2) could not be fixed + * to use the real id without the two sides then fighting over the same object: + * one registered `app.`, the other `'sys_metadata'`, and `registerObject` + * throws `already owned by package …` on the second claim. + * + * ## Why the provenance stamp is in the same PR and not a follow-up + * + * Moving the key alone re-creates cloud#970. `applyProtection` stamps + * `_provenance: 'package'` on any body handed to `registerObject` with a + * package id and no provenance of its own; `getArtifactItem` reads exactly + * that, `isArtifactBacked` turns true, and `object` declares + * `allowOrgOverride: false` — so the NEXT save of the object the user just + * created answers `403 not_overridable`. The B-minimal counter-example below + * reproduces that on the real registry, so the stamp is pinned by the failure + * it prevents rather than by a comment. + * + * The stamp is applied to a COPY, server-side, overriding whatever the body + * carried: `metadata-read-decorations.ts` deliberately does not strip + * `_provenance`, so a Studio GET → PUT round-trip echoes it back and a + * consumer that trusted the request would be trusting its own output. + * + * This file lives in `@objectstack/objectql` and not next to the code it + * tests because the assertion is about the REAL `SchemaRegistry` — + * ownership clashes, `applyProtection`'s provenance defaulting, + * `getAllObjects` filtering. `@objectstack/objectql` depends on + * `@objectstack/metadata-protocol`, so only this direction can hold both + * halves; the reverse import would close a cycle turbo rejects. + */ + +import { describe, expect, it } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SchemaRegistry } from './registry.js'; +// [#4550 / #5480] The producer's OWN write-verb dispatch decisions, so this +// double cannot accept a call `ObjectQL.delete` / `ObjectQL.update` refuses. +import { assertEngineDeleteDispatch } from './engine-delete-dispatch.js'; +import { assertEngineUpdateDispatch } from './engine-update-dispatch.js'; + +/** A Studio authoring workspace id — writable under ADR-0070. */ +const APP_PKG = 'app.myapp'; +const OTHER_PKG = 'app.otherapp'; +/** The key a package-less overlay write keeps. */ +const SENTINEL = 'sys_metadata'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum?: string; + version?: number; +} + +/** `sys_metadata_history` — the lineage `rollbackMetaItem` resolves through. */ +interface HistoryRow { + id: string; + event_seq: number; + type: string; + name: string; + version: number; + operation_type: string; + metadata: string | null; + checksum: string | null; + organization_id: string | null; + recorded_at: string; +} + +function matches(r: Record, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (v === undefined) continue; + if ((r as any)[k] !== v) return false; + } + return true; +} + +function keyOf(w: Record) { + return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; +} + +function makeHarness() { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + const rows = new Map(); + const historyRows: HistoryRow[] = []; + const synced: string[] = []; + let nextId = 0; + const findRow = (w: Record) => { + for (const [k, r] of rows) if (matches(r, w)) return { key: k, row: r }; + return null; + }; + const engine: any = { + registry, + async findOne(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_history') { + return historyRows.find((h) => matches(h as any, opts.where)) ?? null; + } + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_history') { + return historyRows.filter((h) => matches(h as any, opts.where)); + } + return Array.from(rows.values()).filter((r) => matches(r, opts.where)); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_history') { + const h = { id: `h_${++nextId}`, ...(data as any) } as HistoryRow; + historyRows.push(h); + return { id: h.id }; + } + if (table !== 'sys_metadata') return { id: 'side_table' }; + const row = { id: `r_${++nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(table: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + if (table !== 'sys_metadata') return { id: null }; + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(_t: string, opts?: Record) { + assertEngineDeleteDispatch(opts); + return { deleted: 0 }; + }, + async syncObjectSchema(name: string) { synced.push(name); }, + }; + // A PROJECT kernel (`environmentId` set) — the topology cloud#970 was + // reported on, and the only one where `saveMetaItem`'s overlay gate is + // engaged at all. + const protocol = new ObjectStackProtocolImplementation(engine, undefined, 'env_test'); + return { registry, protocol, rows, historyRows, synced }; +} + +function objectBody(name: string, extra?: Record) { + return { + name, + label: 'Invoice', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + amount: { name: 'amount', type: 'number', label: 'Amount' }, + }, + ...extra, + }; +} + +/** The owning contributor recorded for `name` (no namespace → fqn === name). */ +const owner = (registry: SchemaRegistry, name: string) => + registry.getObjectOwner(name); + +describe('#4636 — object write path keys ownership by the real package id', () => { + it('records the request packageId as the contributor, not the sentinel', async () => { + const { registry, protocol } = makeHarness(); + + const res = await protocol.saveMetaItem({ + type: 'object', + name: 'myapp_invoice', + packageId: APP_PKG, + item: objectBody('myapp_invoice'), + }); + + expect(res.success).toBe(true); + expect(owner(registry, 'myapp_invoice')?.packageId).toBe(APP_PKG); + }); + + it('keeps the sentinel for a package-less write', async () => { + const { registry, protocol } = makeHarness(); + + await protocol.saveMetaItem({ + type: 'object', + name: 'global_invoice', + item: objectBody('global_invoice'), + }); + + expect(owner(registry, 'global_invoice')?.packageId).toBe(SENTINEL); + }); + + it('getAllObjects(packageId) sees a just-created object on the FIRST save', async () => { + const { registry, protocol } = makeHarness(); + + await protocol.saveMetaItem({ + type: 'object', + name: 'myapp_invoice', + packageId: APP_PKG, + item: objectBody('myapp_invoice'), + }); + + // The sidebar package filter (runtime `meta.ts` → `getAllObjects`). + // Pre-fix this was empty until something re-registered the object. + expect(registry.getAllObjects(APP_PKG).map((o: any) => o.name)).toEqual(['myapp_invoice']); + // …and it does NOT leak into another package's filter. + expect(registry.getAllObjects(OTHER_PKG)).toEqual([]); + }); +}); + +describe('#4636 — `_provenance: \'org\'` is stamped by the SERVER', () => { + it('stamps org provenance when the body carries none', async () => { + const { registry, protocol } = makeHarness(); + + await protocol.saveMetaItem({ + type: 'object', + name: 'myapp_invoice', + packageId: APP_PKG, + item: objectBody('myapp_invoice'), + }); + + expect((owner(registry, 'myapp_invoice')?.definition as any)?._provenance).toBe('org'); + }); + + it('OVERRIDES a client-supplied `_provenance: \'package\'` — the request never wins', async () => { + const { registry, protocol } = makeHarness(); + + // The exact shape a Studio GET → PUT round-trip can produce: + // `metadata-read-decorations.ts` does not strip `_provenance`, so the + // served document hands it back to the write. + await protocol.saveMetaItem({ + type: 'object', + name: 'myapp_invoice', + packageId: APP_PKG, + item: objectBody('myapp_invoice', { _provenance: 'package' }), + }); + + expect((owner(registry, 'myapp_invoice')?.definition as any)?._provenance).toBe('org'); + }); + + it('does not write the registry stamps back onto the caller\'s body', async () => { + const { protocol } = makeHarness(); + const item = objectBody('myapp_invoice'); + + await protocol.saveMetaItem({ + type: 'object', + name: 'myapp_invoice', + packageId: APP_PKG, + item, + }); + + // The stamp lands on a COPY: the persisted body (and the caller's + // object) keep exactly what the author wrote. + expect((item as any)._provenance).toBeUndefined(); + expect((item as any)._packageId).toBeUndefined(); + }); +}); + +describe('#4636 — cloud#970 counter-example: a freshly created app stays editable', () => { + it('a second save of the same package-bound object SUCCEEDS and evolves the schema', async () => { + const { registry, protocol, rows } = makeHarness(); + + await protocol.saveMetaItem({ + type: 'object', + name: 'myapp_invoice', + packageId: APP_PKG, + item: objectBody('myapp_invoice'), + }); + + // The edit a user makes one minute later: add a field. + const evolved = objectBody('myapp_invoice'); + (evolved.fields as any).due_date = { name: 'due_date', type: 'date', label: 'Due' }; + const second = await protocol.saveMetaItem({ + type: 'object', + name: 'myapp_invoice', + packageId: APP_PKG, + item: evolved, + }); + + expect(second.success).toBe(true); + // In memory: the registry serves the evolved schema, so CRUD on the + // new field works without a restart. + expect(Object.keys((registry.getObject('myapp_invoice') as any).fields)).toContain('due_date'); + // On disk: one row, still bound to its package, carrying the new body. + const stored = Array.from(rows.values()).filter((r) => r.name === 'myapp_invoice'); + expect(stored).toHaveLength(1); + expect(stored[0].package_id).toBe(APP_PKG); + expect(Object.keys(JSON.parse(stored[0].metadata).fields)).toContain('due_date'); + }); + + it('B-minimal counter-example: the SAME key without the stamp answers 403 not_overridable', async () => { + const { registry, protocol } = makeHarness(); + + // Exactly what `applyObjectRegistryMutation` would do if it moved the + // ownership key and left the provenance stamp out — the shape measured + // as "B-minimal" on this issue. Nothing else about the run differs. + registry.registerObject(objectBody('myapp_invoice') as any, APP_PKG); + + await expect(protocol.saveMetaItem({ + type: 'object', + name: 'myapp_invoice', + packageId: APP_PKG, + item: objectBody('myapp_invoice'), + })).rejects.toThrow(/not_overridable/); + + // The mechanism, stated so a future reader does not have to re-derive + // it: with no `_provenance`, `applyProtection` defaults the row to + // package provenance, and the registry then answers "code artifact". + expect((owner(registry, 'myapp_invoice')?.definition as any)?._provenance).toBe('package'); + }); +}); + +describe('#4636 — rollback re-registers under the row\'s own package binding', () => { + it('resolves the ownership key from the ROW: a package-less rollback keeps the sentinel', async () => { + const { registry, protocol } = makeHarness(); + + await protocol.saveMetaItem({ + type: 'object', + name: 'global_invoice', + item: objectBody('global_invoice'), + }); + const evolved = objectBody('global_invoice'); + (evolved.fields as any).due_date = { name: 'due_date', type: 'date', label: 'Due' }; + await protocol.saveMetaItem({ + type: 'object', + name: 'global_invoice', + item: evolved, + }); + + const back = await protocol.rollbackMetaItem({ + type: 'object', + name: 'global_invoice', + toVersion: 1, + }); + + expect(back.success).toBe(true); + // `rollbackMetaItem` has no `packageId` parameter — the key comes from + // the row, which here has none, so the sentinel is the right answer and + // the registry serves the restored body. + expect(owner(registry, 'global_invoice')?.packageId).toBe(SENTINEL); + expect(Object.keys((registry.getObject('global_invoice') as any).fields)).not.toContain('due_date'); + expect((owner(registry, 'global_invoice')?.definition as any)?._provenance).toBe('org'); + }); + + /** + * TRIPWIRE — this pins a DEFECT, not a desired behaviour. + * + * The package-bound half of the ownership key resolved above is + * unreachable through `rollbackMetaItem` today, and not because of + * anything in this PR: `SysMetadataRepository.restoreVersion` calls `put` + * with no `packageId`, `put` scopes its existing-row lookup with + * `whereFor(ref, state, opts.packageId ?? null)`, and `null` is a + * PREDICATE (`package_id IS NULL`) rather than "any package". So the + * lookup misses the package-bound row it is restoring, reads its parent + * hash as null, and every rollback of a package-bound row answers 409 + * before any registry write-through runs. Filed separately — see the + * `out_of_scope_findings` link in this PR. + * + * When that is fixed this test FAILS, which is the point: whoever fixes it + * must then assert what the write-through does with the key, and the + * assertion is already written one test up. + */ + it('[tripwire] a package-bound rollback still 409s — a pre-existing repository-scoping defect', async () => { + const { protocol } = makeHarness(); + + await protocol.saveMetaItem({ + type: 'object', + name: 'myapp_invoice', + packageId: APP_PKG, + item: objectBody('myapp_invoice'), + }); + const evolved = objectBody('myapp_invoice'); + (evolved.fields as any).due_date = { name: 'due_date', type: 'date', label: 'Due' }; + await protocol.saveMetaItem({ + type: 'object', + name: 'myapp_invoice', + packageId: APP_PKG, + item: evolved, + }); + + await expect(protocol.rollbackMetaItem({ + type: 'object', + name: 'myapp_invoice', + toVersion: 1, + })).rejects.toThrow(/metadata_conflict/); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index aae243329b..917bcb3a35 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -797,11 +797,15 @@ export class NamespaceConflictError extends Error { * artifact? (ADR-0010 `_provenance`: `'package'` for loader-introduced items, * `'org'` for tenant-authored.) * - * `_packageId !== 'sys_metadata'` alone cannot answer it. That sentinel only - * holds on the save path; the boot-time rehydration of `sys_metadata` registers - * each row under its REAL package id (`app.`), which is exactly what every - * runtime-authored item has carried since packages became mandatory. So a - * tenant's own overlay came back from a kernel rebuild looking like a code + * `_packageId !== 'sys_metadata'` alone cannot answer it. That sentinel marks + * one thing only — an overlay row bound to no package. A row that IS bound to + * one is keyed by its real package id on the save path (#4636 PR1) and by the + * boot-time rehydration of `sys_metadata` (#4636 PR2 — the branch still reads a + * camelCase key off a snake_case row, so today it falls back to the sentinel; + * that half of this paragraph describes the contract, not yet the code). Either + * way the key is `app.`, which is exactly what every code-shipped item + * carries too, so the sentinel test cannot tell them apart. A tenant's own + * overlay came back from a kernel rebuild looking like a code * artifact, and the protocol's overlay gate refused the next write to it with * `not_overridable` — an app the user had just built through Studio/AI became * permanently un-editable at the first kernel rebuild (cloud#970). Provenance is From 4c9fd92a5270d1be7210f6a7b17b926284c47c2b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 11:01:46 +0000 Subject: [PATCH 2/2] =?UTF-8?q?test(objectql):=20tripwire=20=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E6=8C=87=E5=90=91=E5=B7=B2=E7=AB=8B=E5=8D=95=E7=9A=84?= =?UTF-8?q?=20#6215?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回滚的 package-bound 分支为何今天不可达,原本只写在 PR 正文里;把 issue 号写进 测试本体,读到这条 tripwire 的人不必回翻 PR 就知道它钉的是哪个缺陷。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We --- .../objectql/src/protocol-writepath-object-ownership.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/objectql/src/protocol-writepath-object-ownership.test.ts b/packages/objectql/src/protocol-writepath-object-ownership.test.ts index 3c599d954e..664a35146c 100644 --- a/packages/objectql/src/protocol-writepath-object-ownership.test.ts +++ b/packages/objectql/src/protocol-writepath-object-ownership.test.ts @@ -356,8 +356,7 @@ describe('#4636 — rollback re-registers under the row\'s own package binding', * PREDICATE (`package_id IS NULL`) rather than "any package". So the * lookup misses the package-bound row it is restoring, reads its parent * hash as null, and every rollback of a package-bound row answers 409 - * before any registry write-through runs. Filed separately — see the - * `out_of_scope_findings` link in this PR. + * before any registry write-through runs. Filed as #6215. * * When that is fixed this test FAILS, which is the point: whoever fixes it * must then assert what the write-through does with the key, and the