Skip to content

Commit 1ee48bc

Browse files
os-zhuangclaude
andauthored
fix(objectql,metadata-protocol): a tenant-authored overlay must not read back as a code artifact (#4429)
`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). 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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 26bb053 commit 1ee48bc

4 files changed

Lines changed: 128 additions & 5 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/metadata-protocol": patch
4+
---
5+
6+
fix(objectql,metadata-protocol): a tenant-authored overlay must not read back as a code artifact
7+
8+
`saveMetaItem` refuses to write an artifact-backed item of a type that has not
9+
opted into overlay writes (`not_overridable`), and it asks
10+
`registry.getArtifactItem` who is artifact-backed. That answer was "anything
11+
whose `_packageId` is not the literal string `sys_metadata`" — a sentinel that
12+
only holds on the save path. The boot-time rehydration of `sys_metadata`
13+
registers each row under its REAL package id (`app.<slug>`), which every
14+
runtime-authored item has carried since packages became mandatory.
15+
16+
So an app the user had just built through Studio (or the AI build agent) came
17+
back from the next kernel rebuild looking code-shipped, and the following edit
18+
was refused with a 403 — permanently. Live capture: two identical `modify_field`
19+
calls on the same object seconds apart, the first published LIVE and the second
20+
`not_overridable`, because the first one's auto-publish triggered the rebuild in
21+
between (cloud#970).
22+
23+
Provenance is the axis that actually separates the two (ADR-0010 `_provenance`:
24+
`'package'` for loader-introduced items, `'org'` for tenant-authored), so ask it:
25+
the `sys_metadata` hydration now stamps `_provenance: 'org'`, and
26+
`getArtifactItem` no longer treats such an item as an artifact. An item with no
27+
provenance under a real package id is unchanged, so nothing that was protected
28+
becomes writable.

packages/metadata-protocol/src/protocol.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8163,7 +8163,19 @@ export class ObjectStackProtocolImplementation implements
81638163
);
81648164
}
81658165
if (normalizedType === 'object') {
8166-
this.engine.registry.registerObject(data as any, record.packageId || 'sys_metadata');
8166+
// Every row here came from `sys_metadata` — a TENANT-authored
8167+
// overlay, whatever package it is bound to. Say so (ADR-0010
8168+
// `_provenance: 'org'`), because the package id alone reads as
8169+
// code provenance: registering under the real `app.<slug>`
8170+
// made the registry's artifact lookup claim the row was
8171+
// code-shipped, and `saveMetaItem`'s overlay gate then refused
8172+
// the very next write with `not_overridable`. An app the user
8173+
// had just built became un-editable at the first kernel
8174+
// rebuild (cloud#970).
8175+
this.engine.registry.registerObject(
8176+
{ ...(data as Record<string, unknown>), _provenance: 'org' } as any,
8177+
record.packageId || 'sys_metadata',
8178+
);
81678179
} else {
81688180
// Same envelope graft as the getMetaItems hydration:
81698181
// the plain-key entry shadows any packaged artifact,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0010 provenance vs. the overlay gate — a tenant-authored overlay must not
5+
* read back as a code artifact.
6+
*
7+
* `saveMetaItem` refuses to write an artifact-backed item of a type that has not
8+
* opted into overlay writes (`not_overridable`), and it asks
9+
* `registry.getArtifactItem` who is artifact-backed. That answer used to be
10+
* "anything whose `_packageId` is not the string `sys_metadata`" — a sentinel
11+
* that only ever holds on the save path. The boot-time rehydration of
12+
* `sys_metadata` registers each row under its REAL package id (`app.<slug>`),
13+
* which every runtime-authored item has carried since packages became
14+
* mandatory. So a Studio/AI-built app came back from the next kernel rebuild
15+
* looking code-shipped, and the following edit was refused 403 — permanently
16+
* (cloud#970: two identical `modify_field` calls on the same object, seconds
17+
* apart, the first LIVE and the second `not_overridable`).
18+
*/
19+
20+
import { describe, it, expect } from 'vitest';
21+
import { SchemaRegistry } from './registry.js';
22+
23+
const objectBody = (name: string, extra: Record<string, unknown> = {}) => ({
24+
name,
25+
fields: { title: { type: 'text' } },
26+
...extra,
27+
});
28+
29+
describe('getArtifactItem — provenance decides, not the package id', () => {
30+
it('does NOT report a tenant-authored object as artifact-backed, even under a real package id', () => {
31+
const registry = new SchemaRegistry();
32+
// Exactly what loadMetaFromDb does for a sys_metadata row of an AI-built app.
33+
registry.registerObject(objectBody('eymm_project', { _provenance: 'org' }) as never, 'app.eymm');
34+
expect(registry.getArtifactItem('object', 'eymm_project')).toBeUndefined();
35+
});
36+
37+
it('still reports a code-shipped object as artifact-backed', () => {
38+
const registry = new SchemaRegistry();
39+
registry.registerObject(objectBody('billing_invoice', { _provenance: 'package' }) as never, 'com.acme.billing');
40+
expect(registry.getArtifactItem('object', 'billing_invoice')).toBeDefined();
41+
});
42+
43+
it('treats an object with no provenance under a real package id as an artifact (unchanged)', () => {
44+
// The loader does not always stamp provenance; absence must keep the old
45+
// answer so nothing that WAS protected silently becomes writable.
46+
const registry = new SchemaRegistry();
47+
registry.registerObject(objectBody('legacy_thing') as never, 'com.acme.legacy');
48+
expect(registry.getArtifactItem('object', 'legacy_thing')).toBeDefined();
49+
});
50+
51+
it('keeps the sys_metadata sentinel working', () => {
52+
const registry = new SchemaRegistry();
53+
registry.registerObject(objectBody('runtime_thing') as never, 'sys_metadata');
54+
expect(registry.getArtifactItem('object', 'runtime_thing')).toBeUndefined();
55+
});
56+
57+
it('applies the same rule to non-object metadata types', () => {
58+
const registry = new SchemaRegistry();
59+
registry.registerItem('view', { name: 'v_org', _packageId: 'app.eymm', _provenance: 'org' } as never, 'name' as never);
60+
registry.registerItem('view', { name: 'v_pkg', _packageId: 'com.acme.billing', _provenance: 'package' } as never, 'name' as never);
61+
expect(registry.getArtifactItem('view', 'v_org')).toBeUndefined();
62+
expect(registry.getArtifactItem('view', 'v_pkg')).toBeDefined();
63+
});
64+
});

packages/objectql/src/registry.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,25 @@ export class NamespaceConflictError extends Error {
579579
}
580580
}
581581

582+
/**
583+
* Is this registered item a TENANT-authored overlay rather than a code-shipped
584+
* artifact? (ADR-0010 `_provenance`: `'package'` for loader-introduced items,
585+
* `'org'` for tenant-authored.)
586+
*
587+
* `_packageId !== 'sys_metadata'` alone cannot answer it. That sentinel only
588+
* holds on the save path; the boot-time rehydration of `sys_metadata` registers
589+
* each row under its REAL package id (`app.<slug>`), which is exactly what every
590+
* runtime-authored item has carried since packages became mandatory. So a
591+
* tenant's own overlay came back from a kernel rebuild looking like a code
592+
* artifact, and the protocol's overlay gate refused the next write to it with
593+
* `not_overridable` — an app the user had just built through Studio/AI became
594+
* permanently un-editable at the first kernel rebuild (cloud#970). Provenance is
595+
* the axis that actually distinguishes the two, so ask it.
596+
*/
597+
function isTenantAuthored(item: unknown): boolean {
598+
return (item as { _provenance?: unknown } | null | undefined)?._provenance === 'org';
599+
}
600+
582601
export class SchemaRegistry {
583602
// ==========================================
584603
// Logging control
@@ -1314,7 +1333,7 @@ export class SchemaRegistry {
13141333
getArtifactItem<T>(type: string, name: string, currentPackageId?: string): T | undefined {
13151334
if (type === 'object' || type === 'objects') {
13161335
const obj = this.getObject(name) as any;
1317-
return obj && obj._packageId && obj._packageId !== 'sys_metadata'
1336+
return obj && obj._packageId && obj._packageId !== 'sys_metadata' && !isTenantAuthored(obj)
13181337
? (obj as T)
13191338
: undefined;
13201339
}
@@ -1326,12 +1345,12 @@ export class SchemaRegistry {
13261345
// iteration order.
13271346
if (currentPackageId) {
13281347
const local = collection.get(`${currentPackageId}:${name}`) as any;
1329-
if (local && local._packageId && local._packageId !== 'sys_metadata') return local as T;
1348+
if (local && local._packageId && local._packageId !== 'sys_metadata' && !isTenantAuthored(local)) return local as T;
13301349
}
13311350
for (const [key, item] of collection) {
13321351
if (key !== name && key.endsWith(`:${name}`)) {
13331352
const it = item as any;
1334-
if (it && it._packageId && it._packageId !== 'sys_metadata') return item as T;
1353+
if (it && it._packageId && it._packageId !== 'sys_metadata' && !isTenantAuthored(it)) return item as T;
13351354
}
13361355
}
13371356
// Bare-key fallback: a runtime/DB overlay rehydrated under the plain name.
@@ -1344,7 +1363,7 @@ export class SchemaRegistry {
13441363
// the legacy best-effort first-match.
13451364
const direct = collection.get(name) as any;
13461365
if (
1347-
direct && direct._packageId && direct._packageId !== 'sys_metadata' &&
1366+
direct && direct._packageId && direct._packageId !== 'sys_metadata' && !isTenantAuthored(direct) &&
13481367
(!currentPackageId || direct._packageId === currentPackageId)
13491368
) {
13501369
return direct as T;

0 commit comments

Comments
 (0)