Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/runtime-overlay-not-artifact.md
Original file line number Diff line number Diff line change
@@ -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.<slug>`), 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.
14 changes: 13 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<slug>`
// 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<string, unknown>), _provenance: 'org' } as any,
record.packageId || 'sys_metadata',
);
} else {
// Same envelope graft as the getMetaItems hydration:
// the plain-key entry shadows any packaged artifact,
Expand Down
64 changes: 64 additions & 0 deletions packages/objectql/src/registry-tenant-authored-artifact.test.ts
Original file line number Diff line number Diff line change
@@ -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.<slug>`),
* 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<string, unknown> = {}) => ({
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();
});
});
27 changes: 23 additions & 4 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<slug>`), 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
Expand Down Expand Up @@ -1314,7 +1333,7 @@ export class SchemaRegistry {
getArtifactItem<T>(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;
}
Expand All @@ -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.
Expand All @@ -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;
Expand Down
Loading