diff --git a/.changeset/derived-provenance-write-door-and-hydration.md b/.changeset/derived-provenance-write-door-and-hydration.md new file mode 100644 index 0000000000..0359b2065a --- /dev/null +++ b/.changeset/derived-provenance-write-door-and-hydration.md @@ -0,0 +1,16 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +Stop persisting the caller's `_packageId` / `_packageVersion` / `_provenance`, and restate tenant authorship at hydration for every metadata type. + +Two seams let a tenant lock themselves out of their own metadata. `saveMetaItem` persisted those three keys verbatim — `metadata-read-decorations.ts` deliberately does not strip `_provenance` from a served document, so the ordinary Studio `GET /meta/app/x` → edit → `PUT /meta/app/x` round trip wrote `_provenance: 'package'` into the tenant's own `sys_metadata` row. For every non-`object` type, boot and read-side hydration then registered that stored body as-is, so `SchemaRegistry.getArtifactItem`'s bare-key fallback accepted the overlay as a code artifact, `isArtifactBacked` turned true, and every later write was refused `NOT_OVERRIDABLE` — permanently, because the next boot re-derived the same verdict from the same row. The refusal said the item is "provided by a code package" when no code package published it at all. + +Both halves are closed, because they cover different populations: + +- `saveMetaItem` now drops exactly those three keys from the body it persists, so future writes stop poisoning the corpus. The `_lock*` family is deliberately untouched — a lock is author-declarable and dropping one is the fail-open direction. +- `hydrateOverlayIntoRegistry` — the one choke point boot, read-side and write-through hydration already share — now states `_provenance: 'org'` on a copy before merging the artifact envelope, so rows already written become harmless without being rewritten. That also covers the column path: `getMetaItems` re-stamps `_packageId` onto the body from the row's `package_id` column, which the write-door strip cannot reach. + +The three keys are read-side derived — `mergeArtifactProtection` recomputes them from the artifact on every read — so nothing an author wrote is lost and no accepted key or value changes. Where a real artifact exists its envelope still wins over both the stored copy and the restatement: ADR-0010 §3.3 precedence is unchanged, and an item genuinely shipped by a code package is still refused `NOT_OVERRIDABLE`. + +One residual is deliberately left open: hydration corrects the AUTHORIZATION verdict, not the SERVED document. `getMetaItem` / `getMetaItems` return the overlay row's own body, and `mergeArtifactProtection` only fires where an artifact exists — so a row already poisoned at rest becomes editable again while `GET /meta/app/x` keeps serving `_provenance: 'package'` (and the UI keeps badging it as package-provided) until that row is re-saved through the write door or backfilled. The `objectstack-ai/cloud#2069` backfill stays load-bearing for that population; this change does not retire it. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 456a24e53f..d5867c626a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1172,6 +1172,84 @@ function mergeArtifactProtection(item: unknown, artifactItem: unknown): unknown return out; } +/** + * [#16702] ADR-0010 §3.3 — the three protection keys that are READ-SIDE + * DERIVED, and therefore must never be persisted from a caller's body. + * + * {@link mergeArtifactProtection} recomputes all three from the artifact on + * every read, so a copy stored inside a `sys_metadata` body is never + * load-bearing: removing it is observable ONLY where that copy was a lie. + * + * ⛔ The `_lock*` family is deliberately NOT here, though it shares the + * underscore spelling and the same ADR-0010 envelope. A lock is + * AUTHOR-DECLARABLE (`protection.lock`, translated into `_lock*` by + * `applyProtection`), so dropping one is the FAIL-OPEN direction — cloud PR + * #2065 drew that line at its own producer and this door follows it. + * + * ⛔ Nor is this a second `METADATA_READ_DECORATIONS`. That list is shared + * with every consumer that re-parses a SERVED document (`spec`'s + * `metadata-read-decorations.ts`), and its header states on purpose that the + * protection envelope stays on a served body so provenance survives a + * re-parse. This strip is scoped to the WRITE door alone. + */ +const DERIVED_PROVENANCE_KEYS = ['_packageId', '_packageVersion', '_provenance'] as const; + +/** + * [#16702] Remove {@link DERIVED_PROVENANCE_KEYS} from a body about to be + * PERSISTED into `sys_metadata`. + * + * A **silent** strip, for the same reason {@link stripReadDecorations} is + * silent: the standard Studio `GET` → edit → `PUT` round-trip echoes whatever + * the served document carried, and refusing that round-trip would be hostile + * for keys the server stamped itself. What it restores is the invariant the + * `_provenance: 'org'` docblock in {@link + * ObjectStackProtocolImplementation.applyObjectRegistryMutation} already + * states: every row this door writes is tenant-authored by definition, so the + * server states that fact rather than reading it back from the caller. + * + * Returns the SAME reference when there is nothing to strip, so the common + * path allocates nothing. Non-object inputs pass through — the caller's own + * validation owns those. + */ +function stripDerivedProvenance(item: unknown): unknown { + if (!item || typeof item !== 'object' || Array.isArray(item)) return item; + const dict = item as Record; + if (!DERIVED_PROVENANCE_KEYS.some((k) => k in dict)) return item; + const next = { ...dict }; + for (const k of DERIVED_PROVENANCE_KEYS) delete next[k]; + return next; +} + +/** + * [#16702] State what every `sys_metadata` row IS — tenant-authored, ADR-0010 + * `_provenance: 'org'` — on a COPY of a body about to be REGISTERED. + * + * The same sentence {@link + * ObjectStackProtocolImplementation.applyObjectRegistryMutation} and the boot + * `object` limb already write, said once for every OTHER type at the one + * hydration choke point they do not share. Without it the row's own bytes + * decide: `isCodeArtifactBody` accepts a truthy non-sentinel `_packageId` with + * non-`org` provenance, `SchemaRegistry.getArtifactItem`'s bare-key fallback + * returns the overlay AS an artifact, `isArtifactBacked` turns true, and + * `saveMetaItem`'s overlay gate refuses the tenant's next write to their own + * item with `NOT_OVERRIDABLE` — permanently, because the next boot re-derives + * the same verdict from the same row (cloud#970's shape, for non-`object` + * types). + * + * ⚠️ Its ONE caller applies it BEFORE {@link mergeArtifactProtection}, and the + * order is the whole contract: where a real artifact exists the artifact's + * envelope still overwrites `_provenance` (and `_packageId` / + * `_packageVersion` / `_lock*`) on the way out, so package protection is + * untouched — ADR-0010 §3.3 precedence is unchanged in both directions. + * + * On a COPY, always: `registerItem` hands the body to `applyProtection`, which + * mutates in place, and the callers own their `data`. + */ +function stateTenantAuthorship(data: unknown): unknown { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data; + return { ...(data as Record), _provenance: 'org' }; +} + /** * ADR-0048 (#1828) — composite dedup identity for the unscoped metadata list. * @@ -14038,7 +14116,19 @@ export class ObjectStackProtocolImplementation implements const registry: any = (this.engine as any)?.registry; if (!registry || typeof registry.registerItem !== 'function') return false; const artifact = this.lookupArtifactItem(type, (data as any).name, options.packageId ?? undefined); - registry.registerItem(type, mergeArtifactProtection(data, artifact), 'name' as any); + // [#16702] Say what this row IS before the artifact envelope is grafted + // on top of it. Every body reaching this hydrator came out of a + // `sys_metadata` write and is therefore tenant-authored by definition + // (ADR-0010 `_provenance: 'org'`) — the same sentence the `object` + // branches of this class already write, said once here for every OTHER + // type. It makes rows ALREADY poisoned at rest harmless without + // rewriting them, and it covers the column path the write-door strip + // cannot reach: `getMetaItems` re-stamps `_packageId` onto the body + // from the row's `package_id` COLUMN a few frames up, before handing it + // here. ⚠️ BEFORE the merge, never after — where a real artifact + // exists its envelope must still win (ADR-0010 §3.3), and it does, + // because {@link mergeArtifactProtection} overwrites `_provenance` last. + registry.registerItem(type, mergeArtifactProtection(stateTenantAuthorship(data), artifact), 'name' as any); this.hydrateExpandedViewItems(type, data, options, registry); return true; } @@ -14899,6 +14989,21 @@ export class ObjectStackProtocolImplementation implements // Placed first so the destructive-change diff, the schema gate, the // authoring gate and the persisted body all see the same document. request.item = stripReadDecorations(request.item); + // [#16702] …and the three DERIVED protection keys, for the same reason + // one beat later. `metadata-read-decorations.ts` deliberately does NOT + // strip `_provenance` from a SERVED document (a served body must keep + // its provenance on re-parse), so the very same round-trip echoed + // `_packageId` / `_packageVersion` / `_provenance: 'package'` straight + // back into the tenant's own row — and for every non-`object` type the + // hydrated row's own bytes then decided it was a code artifact, closing + // the tenant out of their own item with `NOT_OVERRIDABLE` forever. The + // read side recomputes all three from the artifact on every read + // ({@link mergeArtifactProtection}), so nothing is lost by not storing + // them. See {@link DERIVED_PROVENANCE_KEYS} for why `_lock*` is NOT in + // the set. Placed alongside the decoration strip so the + // destructive-change diff, the schema gate, the authoring gate and the + // persisted body all still see one document. + request.item = stripDerivedProvenance(request.item); // [#6562] …and OUR OWN injected system columns, for the same reason and // at the same moment. `governServedItem` now serves the EFFECTIVE object // schema, so the very same Studio round-trip would otherwise persist diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index 37ced13f10..c45b0fbb8a 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -1035,9 +1035,14 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { // Assert — items should be restored into the registry const registry = (kernel.getService('objectql') as any).registry; + // [#16702] `_provenance: 'org'` is the SERVER's own sentence about every + // `sys_metadata` row, stated by the shared hydrator — the same one the + // `object` branch has always made. Kept as an exact-shape assertion so an + // UNEXPECTED extra key on a restored app still reds this pin. expect(registry.getAllApps()).toContainEqual({ name: 'custom_crm', label: 'Custom CRM', + _provenance: 'org', }); }); diff --git a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts index 7b98557c2d..67f1487414 100644 --- a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts +++ b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts @@ -135,11 +135,18 @@ describe('loadMetaFromDb — ADR-0048 package-scoped protection graft at boot (# expect(direct._provenance).toBe('package'); }); - it('registers the row unchanged when artifacts have not loaded yet (boot-order no-op)', async () => { + it('grafts NO artifact envelope when artifacts have not loaded yet (boot-order no-op)', async () => { // Empty registry at hydration time — the scoped lookup finds // nothing, exactly like the unscoped one did, and the row // registers without a grafted envelope. Artifact-after-hydration // boot orders are unaffected by the scoping. + // + // [#16702] What the row does carry is the SERVER's own sentence about + // it — `_provenance: 'org'`, the same one the `object` branch has + // always stated — because every row this hydrator sees came out of a + // `sys_metadata` write. That is a statement of authorship, not a graft + // from an artifact: `_lock` and `_packageId` stay absent, which is what + // this case is about. const registry = new SchemaRegistry({ multiTenant: false }); registry.logLevel = 'silent'; const rows = [ @@ -159,7 +166,8 @@ describe('loadMetaFromDb — ADR-0048 package-scoped protection graft at boot (# expect(direct.label).toBe('B Home (customized)'); expect(direct._lock).toBeUndefined(); expect(direct._packageId).toBeUndefined(); - expect(direct._provenance).toBeUndefined(); + // [#16702] NOT `undefined` — the hydrator states tenant authorship. + expect(direct._provenance).toBe('org'); }); it('keeps the legacy best-effort graft for package-less (global) rows', async () => { diff --git a/packages/objectql/src/protocol-derived-provenance-doors.test.ts b/packages/objectql/src/protocol-derived-provenance-doors.test.ts new file mode 100644 index 0000000000..a335a03a3c --- /dev/null +++ b/packages/objectql/src/protocol-derived-provenance-doors.test.ts @@ -0,0 +1,456 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16702 — the two doors that keep a tenant's OWN item editable. + * + * `_packageId` / `_packageVersion` / `_provenance` are READ-SIDE DERIVED: + * `mergeArtifactProtection` recomputes them from the artifact on every read, + * so a copy stored inside a `sys_metadata` body is never load-bearing and its + * removal is observable only where that copy was a lie. Two seams let the lie + * become permanent, and they cover different time windows: + * + * 1. **The write door.** `saveMetaItem` persisted the caller's three keys + * verbatim. `metadata-read-decorations.ts` deliberately does NOT strip + * `_provenance` from a served document, so the ordinary Studio + * `GET /meta/app/x` -> edit -> `PUT /meta/app/x` round trip wrote + * `_provenance: 'package'` into the tenant's own row. + * 2. **Hydration.** For a non-`object` type the stored body was registered + * as-is (`registerItem(type, mergeArtifactProtection(data, artifact))`), + * while the `object` branch already restated `_provenance: 'org'` on a + * copy. So the row's own bytes decided: `isCodeArtifactBody` saw a truthy + * non-sentinel `_packageId` with non-`org` provenance, `getArtifactItem`'s + * bare-key fallback returned the overlay as an artifact, `isArtifactBacked` + * turned true and `saveMetaItem`'s overlay gate refused every later write + * with `NOT_OVERRIDABLE` — permanently, because the next boot re-derived + * the same verdict from the same row. + * + * Only (1) leaves rows already written bricked; only (2) lets the corpus keep + * accumulating lies. Both land here. + * + * ⚠️ The keys stripped are EXACTLY those three. The `_lock*` family is NOT + * touched: a lock is author-declarable, and dropping one is the fail-open + * direction (the line cloud PR #2065 drew at its own producer). + * + * This file lives in `@objectstack/objectql` for the same reason + * `protocol-writepath-object-ownership.test.ts` does: the subject is the REAL + * `SchemaRegistry` reached through a REAL `ObjectQL` engine, and objectql + * depends on metadata-protocol — only this direction holds both halves + * without closing a cycle turbo rejects. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +// The repository's OWN checksum function, so a hand-seeded at-rest row carries +// the `checksum` column a real write would have left. Without it the +// optimistic lock reads `hashSpec(body)` as the parent and `null` as the head +// and refuses the follow-up save with `METADATA_CONFLICT` — a fixture defect +// that would otherwise read as the subject refusing the write. +import { hashSpec } from '@objectstack/metadata-core'; +import { ObjectQL } from './engine.js'; + +const PKG = 'app.sdbh'; +const ENV = 'env_test'; + +const sysMetadataObject = { + name: 'sys_metadata', + label: 'System Metadata', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, + metadata: { name: 'metadata', label: 'Body', type: 'longtext' as const }, + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, + state: { name: 'state', label: 'State', type: 'text' as const }, + version: { name: 'version', label: 'Version', type: 'number' as const }, + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, + }, +}; + +/** + * Minimal stub DRIVER — not an engine double. The engine above it is the real + * `ObjectQL`, so every dispatch rule the protocol depends on is the shipped + * one; only the storage bytes are in memory. Equality-only WHERE, one record + * store per object, shared across "restarts" so a fresh engine reads the same + * rows a previous session wrote. + */ +function makeStubDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {} as any, + async connect() {}, + async disconnect() {}, + async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + // The caller's bound, applied AFTER the filter and by PRESENCE — a + // `find` double that ignores `limit` answers a different query than + // the one it was handed (`pnpm check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, + async rollback() {}, + }; + return { driver, stores }; +} + +/** A REAL engine + REAL SchemaRegistry over `driver`. No code package loaded. */ +async function boot(driver: unknown) { + const engine = new ObjectQL(); + engine.registry.logLevel = 'silent'; + engine.registerDriver(driver as any, true); + await engine.init(); + engine.registry.registerObject(sysMetadataObject as any); + const protocol = new ObjectStackProtocolImplementation(engine as any, undefined, ENV); + return { engine, protocol }; +} + +/** The packaged artifact a real code package would have registered at load. */ +function packagedApp(label: string) { + return { + name: 'pet_hospital', + label, + _packageId: PKG, + _packageVersion: '1.0.0', + _provenance: 'package', + _lock: 'full', + _lockReason: `Shipped by ${PKG}`, + }; +} + + +/** One `sys_metadata` row exactly as a previous session would have left it. */ +function seedRow(stores: Map>>, body: Record) { + stores.set('sys_metadata', new Map([['r_seeded', { + id: 'r_seeded', type: 'app', name: 'pet_hospital', + organization_id: null, package_id: PKG, state: 'active', version: 1, + metadata: JSON.stringify(body), + checksum: hashSpec(body), + }]])); +} + +async function storedBody(engine: ObjectQL, name = 'pet_hospital'): Promise> { + const rows = await engine.find('sys_metadata', { where: { type: 'app', name } }); + expect(rows.length).toBe(1); + return JSON.parse(String((rows[0] as any).metadata)); +} + +describe('#16702 — the card\'s reproduction leg, run as written', () => { + it('write an app carrying _provenance:package -> fresh engine -> loadMetaFromDb() -> saveMetaItem no longer 403s', async () => { + const { driver } = makeStubDriver(); + + // ── session 1: the Studio GET -> PUT round trip's bytes ────────────── + const s1 = await boot(driver); + await s1.protocol.saveMetaItem({ + type: 'app', name: 'pet_hospital', packageId: PKG, + item: { + name: 'pet_hospital', label: 'Pet Hospital', + _packageId: PKG, _packageVersion: '1.0.0', _provenance: 'package', + }, + }); + const stored = await storedBody(s1.engine); + console.log('[#16702 repro] stored row:', JSON.stringify(stored)); + + // ── session 2: fresh engine + protocol over the SAME driver ───────── + const s2 = await boot(driver); + const hydration = await s2.protocol.loadMetaFromDb(); + console.log('[#16702 repro] loadMetaFromDb():', JSON.stringify(hydration)); + expect(hydration).toMatchObject({ loaded: 1, errors: 0, invalid: 0, storeUnavailable: false }); + + // ── the edit that used to be refused, forever ─────────────────────── + const receipt = await s2.protocol.saveMetaItem({ + type: 'app', name: 'pet_hospital', + item: { name: 'pet_hospital', label: 'edit' }, packageId: PKG, + }); + console.log('[#16702 repro] second saveMetaItem receipt:', JSON.stringify(receipt)); + expect(await storedBody(s2.engine)).toMatchObject({ label: 'edit' }); + }); +}); + +describe('#16702 door 1 — saveMetaItem strips exactly the three DERIVED keys', () => { + it('drops _packageId / _packageVersion / _provenance from the body it persists', async () => { + const { driver } = makeStubDriver(); + const { engine, protocol } = await boot(driver); + await protocol.saveMetaItem({ + type: 'app', name: 'pet_hospital', packageId: PKG, + item: { + name: 'pet_hospital', label: 'Pet Hospital', + _packageId: PKG, _packageVersion: '1.0.0', _provenance: 'package', + }, + }); + const body = await storedBody(engine); + expect(body).not.toHaveProperty('_packageId'); + expect(body).not.toHaveProperty('_packageVersion'); + expect(body).not.toHaveProperty('_provenance'); + // …and nothing else was taken with them. + expect(body).toMatchObject({ name: 'pet_hospital', label: 'Pet Hospital' }); + }); + + it('⛔ does NOT touch the _lock* family — dropping a lock is the fail-open direction', async () => { + const { driver } = makeStubDriver(); + const { engine, protocol } = await boot(driver); + await protocol.saveMetaItem({ + type: 'app', name: 'pet_hospital', packageId: PKG, + item: { + name: 'pet_hospital', label: 'Pet Hospital', + _lock: 'full', _lockReason: 'author declared', _lockSource: 'package', + _lockDocsUrl: 'https://example.invalid/locks', + _packageId: PKG, _provenance: 'package', + }, + }); + const body = await storedBody(engine); + expect(body._lock).toBe('full'); + expect(body._lockReason).toBe('author declared'); + expect(body._lockSource).toBe('package'); + expect(body._lockDocsUrl).toBe('https://example.invalid/locks'); + expect(body).not.toHaveProperty('_packageId'); + expect(body).not.toHaveProperty('_provenance'); + }); +}); + +describe('#16702 door 2 — hydration restates the fact for EVERY type, not only `object`', () => { + it('a non-`object` overlay row already poisoned at rest hydrates as tenant-authored', async () => { + const { driver, stores } = makeStubDriver(); + // A row written BEFORE door 1 existed — the population door 1 cannot + // reach and door 2 makes harmless without rewriting it. + seedRow(stores, { + name: 'pet_hospital', label: 'Pet Hospital', + _packageId: PKG, _packageVersion: '1.0.0', _provenance: 'package', + }); + + const { engine, protocol } = await boot(driver); + expect(await protocol.loadMetaFromDb()).toMatchObject({ loaded: 1, errors: 0 }); + + const hydrated = engine.registry.getItem>('app', 'pet_hospital'); + expect(hydrated?._provenance).toBe('org'); + // …and the protocol therefore lets the tenant edit their own app. + await protocol.saveMetaItem({ + type: 'app', name: 'pet_hospital', + item: { name: 'pet_hospital', label: 'edit' }, packageId: PKG, + }); + expect(await storedBody(engine)).toMatchObject({ label: 'edit' }); + }); + + it('the read-side hydration seam (`getMetaItems`) is covered by the same restatement', async () => { + // `getMetaItems` re-stamps `_packageId` onto the body from the row's + // package_id COLUMN before handing it to the shared hydrator, so door 1 + // alone cannot keep that body off the code-artifact test. Door 2 can. + const { driver, stores } = makeStubDriver(); + seedRow(stores, { name: 'pet_hospital', label: 'Pet Hospital' }); + + const engine = new ObjectQL(); + engine.registry.logLevel = 'silent'; + engine.registerDriver(driver as any, true); + await engine.init(); + engine.registry.registerObject(sysMetadataObject as any); + // environmentId omitted — the unscoped (control-plane) kernel is the + // only one whose list read hydrates the process-wide registry. + const protocol = new ObjectStackProtocolImplementation(engine as any); + + await protocol.getMetaItems({ type: 'app' }); + const hydrated = engine.registry.getItem>('app', 'pet_hospital'); + expect(hydrated?._packageId).toBe(PKG); + expect(hydrated?._provenance).toBe('org'); + + await protocol.saveMetaItem({ + type: 'app', name: 'pet_hospital', + item: { name: 'pet_hospital', label: 'edit' }, packageId: PKG, + }); + expect(await storedBody(engine)).toMatchObject({ label: 'edit' }); + }); +}); + +describe('#16702 criterion 3 — NEGATIVE CONTROL: real package protection survives', () => { + it('an app GENUINELY provided by a code package is STILL refused NOT_OVERRIDABLE', async () => { + const { driver } = makeStubDriver(); + const { engine, protocol } = await boot(driver); + // What a code package's loader does: register under the COMPOSITE key. + engine.registry.registerItem('app', packagedApp('Packaged Pet Hospital'), 'name', PKG); + + await expect( + protocol.saveMetaItem({ + type: 'app', name: 'pet_hospital', + item: { name: 'pet_hospital', label: 'edit' }, packageId: PKG, + }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + }); + + it('…and still refused after a boot that hydrated a tenant overlay of the same name', async () => { + const { driver, stores } = makeStubDriver(); + seedRow(stores, { name: 'pet_hospital', label: 'Customized' }); + const { engine, protocol } = await boot(driver); + engine.registry.registerItem('app', packagedApp('Packaged Pet Hospital'), 'name', PKG); + expect(await protocol.loadMetaFromDb()).toMatchObject({ loaded: 1, errors: 0 }); + + await expect( + protocol.saveMetaItem({ + type: 'app', name: 'pet_hospital', + item: { name: 'pet_hospital', label: 'edit' }, packageId: PKG, + }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + }); +}); + +describe('#16702 criterion 5 — mergeArtifactProtection precedence is unchanged', () => { + it('where a real artifact exists its envelope still beats the stored copy AND the restatement', async () => { + const { driver, stores } = makeStubDriver(); + seedRow(stores, { name: 'pet_hospital', label: 'Customized', _lock: 'none' }); + const { engine, protocol } = await boot(driver); + engine.registry.registerItem('app', packagedApp('Packaged Pet Hospital'), 'name', PKG); + expect(await protocol.loadMetaFromDb()).toMatchObject({ loaded: 1, errors: 0 }); + + const hydrated = engine.registry.getItem>('app', 'pet_hospital'); + // The overlay's own authored content still wins for ordinary fields… + expect(hydrated?.label).toBe('Customized'); + // …and the ARTIFACT's protection envelope wins for every protection key, + // over the stored copy AND over door 2's `_provenance: 'org'` stamp. + expect(hydrated?._lock).toBe('full'); + expect(hydrated?._lockReason).toBe(`Shipped by ${PKG}`); + expect(hydrated?._packageId).toBe(PKG); + expect(hydrated?._packageVersion).toBe('1.0.0'); + expect(hydrated?._provenance).toBe('package'); + }); + + it('with NO artifact present the restatement stands and nothing is invented', async () => { + const { driver, stores } = makeStubDriver(); + seedRow(stores, { name: 'pet_hospital', label: 'Customized', _lock: 'none' }); + const { engine, protocol } = await boot(driver); + expect(await protocol.loadMetaFromDb()).toMatchObject({ loaded: 1, errors: 0 }); + + const hydrated = engine.registry.getItem>('app', 'pet_hospital'); + expect(hydrated?._provenance).toBe('org'); + expect(hydrated?._lock).toBe('none'); + expect(hydrated?._packageId).toBeUndefined(); + }); +}); + +describe('#16702 criterion 4 — the `object` branch is correct today and STAYS correct', () => { + // [#8310] The runtime object door requires an authored OWD. + const objectBody = (label: string) => ({ + name: 'pet_visit', + label, + sharingModel: 'private', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + note: { name: 'note', type: 'text', label: 'Note' }, + }, + }); + + it('an `object` overlay row still hydrates as `_provenance: org` and stays editable across a restart', async () => { + const { driver } = makeStubDriver(); + const s1 = await boot(driver); + await s1.protocol.saveMetaItem({ + type: 'object', name: 'pet_visit', packageId: PKG, + item: { + ...objectBody('Pet Visit'), + _packageId: PKG, _packageVersion: '1.0.0', _provenance: 'package', + }, + }); + + // Door 1's strip runs BEFORE `saveMetaItem`'s type branch, so its scope + // is type-AGNOSTIC: an `object` body loses the same three keys at rest. + // Pinned here rather than implied — the `_provenance: 'org'` read below + // comes from door 2's restatement and would stay green on its own even + // if the strip had skipped `object`. + const seededRows = await s1.engine.find('sys_metadata', { where: { type: 'object', name: 'pet_visit' } }); + const seededObject = JSON.parse(String((seededRows[0] as any).metadata)); + expect(seededObject).not.toHaveProperty('_packageId'); + expect(seededObject).not.toHaveProperty('_packageVersion'); + expect(seededObject).not.toHaveProperty('_provenance'); + // …and nothing else was taken with them. + expect(seededObject).toMatchObject({ name: 'pet_visit', label: 'Pet Visit' }); + + const s2 = await boot(driver); + expect(await s2.protocol.loadMetaFromDb()).toMatchObject({ loaded: 1, errors: 0 }); + const obj = s2.engine.registry.getObject('pet_visit') as Record | undefined; + expect(obj?._provenance).toBe('org'); + + await s2.protocol.saveMetaItem({ + type: 'object', name: 'pet_visit', packageId: PKG, + item: objectBody('Pet Visit (edited)'), + }); + const rows = await s2.engine.find('sys_metadata', { where: { type: 'object', name: 'pet_visit' } }); + expect(JSON.parse(String((rows[0] as any).metadata)).label).toBe('Pet Visit (edited)'); + }); + + it('the `object` branch keeps its own registration path — door 2 did not fold it onto the shared one', async () => { + // `applyObjectRegistryMutation` / the boot `object` limb register through + // `registerObject` (contributor layers, ADR-0029 D9.8), never through + // `registerItem`. The observable that tells them apart: an object + // resolves through the CONTRIBUTOR store, so `getObject` answers. + const { driver } = makeStubDriver(); + const s1 = await boot(driver); + await s1.protocol.saveMetaItem({ + type: 'object', name: 'pet_visit', packageId: PKG, item: objectBody('Pet Visit'), + }); + const s2 = await boot(driver); + await s2.protocol.loadMetaFromDb(); + expect(s2.engine.registry.getObject('pet_visit')).toBeDefined(); + expect(s2.engine.registry.getAllObjects(PKG).map((o: any) => o.name)).toContain('pet_visit'); + }); +}); diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index f612521db6..7795188ffa 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -1167,7 +1167,13 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // Should now be in registry const cached = registry.getItem('app', 'test_app'); - expect(cached).toEqual(sampleApp); + // [#16702] `_provenance: 'org'` is the SERVER's own sentence about + // every `sys_metadata` row, stated by the shared hydrator on a copy + // before the artifact envelope is merged — the same statement the + // `object` branch below has always made. Asserted explicitly rather + // than relaxed to `toMatchObject`, so an UNEXPECTED extra key still + // reds this pin. + expect(cached).toEqual({ ...sampleApp, _provenance: 'org' }); }); it('should try alternate type name in DB when primary type has no records', async () => { @@ -1322,8 +1328,14 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { expect(result.loaded).toBe(2); expect(result.errors).toBe(0); - expect(registry.getItem('app', 'test_app')).toEqual(sampleApp); - expect(registry.getItem('app', 'app2')).toEqual(app2); + // [#16702] `_provenance: 'org'` is the SERVER's own sentence about + // every `sys_metadata` row, stated by the shared hydrator on a copy + // before the artifact envelope is merged — the same statement the + // `object` branch below has always made. Asserted explicitly rather + // than relaxed to `toMatchObject`, so an UNEXPECTED extra key still + // reds this pin. + expect(registry.getItem('app', 'test_app')).toEqual({ ...sampleApp, _provenance: 'org' }); + expect(registry.getItem('app', 'app2')).toEqual({ ...app2, _provenance: 'org' }); }); it('should query only active state records', async () => { @@ -1375,7 +1387,13 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { expect(result.loaded).toBe(1); expect(result.errors).toBe(0); - expect(registry.getItem('app', 'test_app')).toEqual(sampleApp); + // [#16702] `_provenance: 'org'` is the SERVER's own sentence about + // every `sys_metadata` row, stated by the shared hydrator on a copy + // before the artifact envelope is merged — the same statement the + // `object` branch below has always made. Asserted explicitly rather + // than relaxed to `toMatchObject`, so an UNEXPECTED extra key still + // reds this pin. + expect(registry.getItem('app', 'test_app')).toEqual({ ...sampleApp, _provenance: 'org' }); }); it('should load records of different types', async () => { @@ -1391,7 +1409,13 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { const result = await protocol.loadMetaFromDb(); expect(result.loaded).toBe(2); - expect(registry.getItem('app', 'test_app')).toEqual(sampleApp); + // [#16702] `_provenance: 'org'` is the SERVER's own sentence about + // every `sys_metadata` row, stated by the shared hydrator on a copy + // before the artifact envelope is merged — the same statement the + // `object` branch below has always made. Asserted explicitly rather + // than relaxed to `toMatchObject`, so an UNEXPECTED extra key still + // reds this pin. + expect(registry.getItem('app', 'test_app')).toEqual({ ...sampleApp, _provenance: 'org' }); // Object schemas pass through registerObject -> applyProtection (ADR-0010 §3.7), // which stamps the internal `_packageId`/`_provenance` envelope markers used by // listItems() filtering, the HTTP dispatcher and the runtime. Those are an