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;