From df9aaf1a73c8c63fdb0decede51528dca9156055 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:16:51 +0800 Subject: [PATCH] fix(objectql,metadata-protocol): a tenant-authored overlay must not read back as a code artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `saveMetaItem` refuses to write an artifact-backed item of a type that has not opted into overlay writes (`not_overridable`), and it asks `registry.getArtifactItem` who is artifact-backed. That answer was "anything whose `_packageId` is not the literal string `sys_metadata`" — a sentinel that only holds on the save path. The boot-time rehydration of `sys_metadata` registers each row under its REAL package id (`app.`), which every runtime-authored item has carried since packages became mandatory. So an app the user had just built through Studio (or the AI build agent) came back from the next kernel rebuild looking code-shipped, and the following edit was refused with a 403 — permanently. Live capture: two identical `modify_field` calls on the same object seconds apart, the first published LIVE and the second `not_overridable`, because the first one's auto-publish triggered the rebuild in between (cloud#970). It is also why the AI authoring skill's "after apply_blueprint, update_metadata to add the roll-up config" instruction pointed at a road that does not exist. Provenance is the axis that actually separates the two (ADR-0010 `_provenance`: `'package'` for loader-introduced, `'org'` for tenant-authored), so ask it: the `sys_metadata` hydration stamps `_provenance: 'org'`, and `getArtifactItem` no longer treats such an item as an artifact. An item with NO provenance under a real package id keeps the old answer, so nothing that was protected silently becomes writable. Co-Authored-By: Claude Opus 5 --- .changeset/runtime-overlay-not-artifact.md | 28 ++++++++ packages/metadata-protocol/src/protocol.ts | 14 +++- .../registry-tenant-authored-artifact.test.ts | 64 +++++++++++++++++++ packages/objectql/src/registry.ts | 27 ++++++-- 4 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 .changeset/runtime-overlay-not-artifact.md create mode 100644 packages/objectql/src/registry-tenant-authored-artifact.test.ts diff --git a/.changeset/runtime-overlay-not-artifact.md b/.changeset/runtime-overlay-not-artifact.md new file mode 100644 index 0000000000..259852c41c --- /dev/null +++ b/.changeset/runtime-overlay-not-artifact.md @@ -0,0 +1,28 @@ +--- +"@objectstack/objectql": patch +"@objectstack/metadata-protocol": patch +--- + +fix(objectql,metadata-protocol): a tenant-authored overlay must not read back as a code artifact + +`saveMetaItem` refuses to write an artifact-backed item of a type that has not +opted into overlay writes (`not_overridable`), and it asks +`registry.getArtifactItem` who is artifact-backed. That answer was "anything +whose `_packageId` is not the literal string `sys_metadata`" — a sentinel that +only holds on the save path. The boot-time rehydration of `sys_metadata` +registers each row under its REAL package id (`app.`), which every +runtime-authored item has carried since packages became mandatory. + +So an app the user had just built through Studio (or the AI build agent) came +back from the next kernel rebuild looking code-shipped, and the following edit +was refused with a 403 — permanently. Live capture: two identical `modify_field` +calls on the same object seconds apart, the first published LIVE and the second +`not_overridable`, because the first one's auto-publish triggered the rebuild in +between (cloud#970). + +Provenance is the axis that actually separates the two (ADR-0010 `_provenance`: +`'package'` for loader-introduced items, `'org'` for tenant-authored), so ask it: +the `sys_metadata` hydration now stamps `_provenance: 'org'`, and +`getArtifactItem` no longer treats such an item as an artifact. An item with no +provenance under a real package id is unchanged, so nothing that was protected +becomes writable. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index fcf507fd32..cddb8e7cc1 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -8163,7 +8163,19 @@ export class ObjectStackProtocolImplementation implements ); } if (normalizedType === 'object') { - this.engine.registry.registerObject(data as any, record.packageId || 'sys_metadata'); + // Every row here came from `sys_metadata` — a TENANT-authored + // overlay, whatever package it is bound to. Say so (ADR-0010 + // `_provenance: 'org'`), because the package id alone reads as + // code provenance: registering under the real `app.` + // made the registry's artifact lookup claim the row was + // code-shipped, and `saveMetaItem`'s overlay gate then refused + // the very next write with `not_overridable`. An app the user + // had just built became un-editable at the first kernel + // rebuild (cloud#970). + this.engine.registry.registerObject( + { ...(data as Record), _provenance: 'org' } as any, + record.packageId || 'sys_metadata', + ); } else { // Same envelope graft as the getMetaItems hydration: // the plain-key entry shadows any packaged artifact, diff --git a/packages/objectql/src/registry-tenant-authored-artifact.test.ts b/packages/objectql/src/registry-tenant-authored-artifact.test.ts new file mode 100644 index 0000000000..e17b56ac61 --- /dev/null +++ b/packages/objectql/src/registry-tenant-authored-artifact.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0010 provenance vs. the overlay gate — a tenant-authored overlay must not + * read back as a code artifact. + * + * `saveMetaItem` refuses to write an artifact-backed item of a type that has not + * opted into overlay writes (`not_overridable`), and it asks + * `registry.getArtifactItem` who is artifact-backed. That answer used to be + * "anything whose `_packageId` is not the string `sys_metadata`" — a sentinel + * that only ever holds on the save path. The boot-time rehydration of + * `sys_metadata` registers each row under its REAL package id (`app.`), + * which every runtime-authored item has carried since packages became + * mandatory. So a Studio/AI-built app came back from the next kernel rebuild + * looking code-shipped, and the following edit was refused 403 — permanently + * (cloud#970: two identical `modify_field` calls on the same object, seconds + * apart, the first LIVE and the second `not_overridable`). + */ + +import { describe, it, expect } from 'vitest'; +import { SchemaRegistry } from './registry.js'; + +const objectBody = (name: string, extra: Record = {}) => ({ + name, + fields: { title: { type: 'text' } }, + ...extra, +}); + +describe('getArtifactItem — provenance decides, not the package id', () => { + it('does NOT report a tenant-authored object as artifact-backed, even under a real package id', () => { + const registry = new SchemaRegistry(); + // Exactly what loadMetaFromDb does for a sys_metadata row of an AI-built app. + registry.registerObject(objectBody('eymm_project', { _provenance: 'org' }) as never, 'app.eymm'); + expect(registry.getArtifactItem('object', 'eymm_project')).toBeUndefined(); + }); + + it('still reports a code-shipped object as artifact-backed', () => { + const registry = new SchemaRegistry(); + registry.registerObject(objectBody('billing_invoice', { _provenance: 'package' }) as never, 'com.acme.billing'); + expect(registry.getArtifactItem('object', 'billing_invoice')).toBeDefined(); + }); + + it('treats an object with no provenance under a real package id as an artifact (unchanged)', () => { + // The loader does not always stamp provenance; absence must keep the old + // answer so nothing that WAS protected silently becomes writable. + const registry = new SchemaRegistry(); + registry.registerObject(objectBody('legacy_thing') as never, 'com.acme.legacy'); + expect(registry.getArtifactItem('object', 'legacy_thing')).toBeDefined(); + }); + + it('keeps the sys_metadata sentinel working', () => { + const registry = new SchemaRegistry(); + registry.registerObject(objectBody('runtime_thing') as never, 'sys_metadata'); + expect(registry.getArtifactItem('object', 'runtime_thing')).toBeUndefined(); + }); + + it('applies the same rule to non-object metadata types', () => { + const registry = new SchemaRegistry(); + registry.registerItem('view', { name: 'v_org', _packageId: 'app.eymm', _provenance: 'org' } as never, 'name' as never); + registry.registerItem('view', { name: 'v_pkg', _packageId: 'com.acme.billing', _provenance: 'package' } as never, 'name' as never); + expect(registry.getArtifactItem('view', 'v_org')).toBeUndefined(); + expect(registry.getArtifactItem('view', 'v_pkg')).toBeDefined(); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 85ea5acec5..fd50683d53 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -579,6 +579,25 @@ export class NamespaceConflictError extends Error { } } +/** + * Is this registered item a TENANT-authored overlay rather than a code-shipped + * 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 + * 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 + * the axis that actually distinguishes the two, so ask it. + */ +function isTenantAuthored(item: unknown): boolean { + return (item as { _provenance?: unknown } | null | undefined)?._provenance === 'org'; +} + export class SchemaRegistry { // ========================================== // Logging control @@ -1314,7 +1333,7 @@ export class SchemaRegistry { getArtifactItem(type: string, name: string, currentPackageId?: string): T | undefined { if (type === 'object' || type === 'objects') { const obj = this.getObject(name) as any; - return obj && obj._packageId && obj._packageId !== 'sys_metadata' + return obj && obj._packageId && obj._packageId !== 'sys_metadata' && !isTenantAuthored(obj) ? (obj as T) : undefined; } @@ -1326,12 +1345,12 @@ export class SchemaRegistry { // iteration order. if (currentPackageId) { const local = collection.get(`${currentPackageId}:${name}`) as any; - if (local && local._packageId && local._packageId !== 'sys_metadata') return local as T; + if (local && local._packageId && local._packageId !== 'sys_metadata' && !isTenantAuthored(local)) return local as T; } for (const [key, item] of collection) { if (key !== name && key.endsWith(`:${name}`)) { const it = item as any; - if (it && it._packageId && it._packageId !== 'sys_metadata') return item as T; + if (it && it._packageId && it._packageId !== 'sys_metadata' && !isTenantAuthored(it)) return item as T; } } // Bare-key fallback: a runtime/DB overlay rehydrated under the plain name. @@ -1344,7 +1363,7 @@ export class SchemaRegistry { // the legacy best-effort first-match. const direct = collection.get(name) as any; if ( - direct && direct._packageId && direct._packageId !== 'sys_metadata' && + direct && direct._packageId && direct._packageId !== 'sys_metadata' && !isTenantAuthored(direct) && (!currentPackageId || direct._packageId === currentPackageId) ) { return direct as T;