From 0ae4226d8bfbd24fcb284b3e6a99e58f619ecb60 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:28:50 +0000 Subject: [PATCH 1/9] fix(objectql): keep the metadata service's copy of a runtime-authored object current after boot Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- packages/objectql/src/plugin.ts | 144 +++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 71de5edbb48..bff396ce527 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -2,7 +2,7 @@ import { ObjectQL, SECURITY_SERVICE_SOURCE_SLOT } from './engine.js'; import { assembleMetadataProtocol } from '@objectstack/metadata-protocol'; -import { readLegacyOrganizationRows } from '@objectstack/metadata-core'; +import { isTenantAuthored, readLegacyOrganizationRows } from '@objectstack/metadata-core'; import type { MetadataAuthoringChannel } from '@objectstack/metadata-protocol'; import { Plugin, PluginContext } from '@objectstack/core'; import { resolveArtifactPackageOrder, artifactPackageId, readDeploymentOrgScopingEntitlement } from '@objectstack/core'; @@ -512,6 +512,10 @@ export class ObjectQLPlugin implements Plugin { const unsubscribe = (protocol as any).onMetadataMutation( (evt: { type: string; name: string; state: string }) => { if (evt?.state === 'draft') return; + // The metadata service's copy of a runtime-authored object follows + // the registry the moment the authoring door lands. See + // {@link followRuntimeObjectCopy}. + if (evt?.type === 'object') this.followRuntimeObjectCopy(ctx, evt); if (evt?.type === 'hook') { void this.resyncAuthoredHooks(ctx).catch((e: any) => { ctx.logger.warn('[ObjectQLPlugin] authored-hook rebind after mutation failed', { @@ -2273,6 +2277,11 @@ export class ObjectQLPlugin implements Plugin { * carries no `_packageId` would have its true package provenance * overwritten with 'metadata-service'. Nothing is stale either: the * registry is the source here, and it already holds what we just copied. + * + * One-shot: what the registry holds AFTER this pass reaches the service + * through {@link followRuntimeObjectCopy} (a runtime-authored object saved or + * deleted through an authoring door) and + * {@link bridgeManifestObjectsToMetadataService} (a package registered late). */ private async bridgeObjectsToMetadataService(ctx: PluginContext): Promise { try { @@ -2322,6 +2331,139 @@ export class ObjectQLPlugin implements Plugin { } } + /** + * Keep the metadata service's copy of a RUNTIME-AUTHORED object current after + * boot, and take it out when the object is deleted. + * + * {@link bridgeObjectsToMetadataService} copies each registry object into the + * `metadata` service once, at boot, and no authoring door writes to that + * service. So on a kernel where the bridge ran, the service's answer for a + * runtime-authored object was whatever the boot saw: an object created + * afterwards had NO copy, a re-saved one kept its boot body, a deleted one + * kept answering. The readers of `IMetadataService.getObject` / `listObjects` + * inherit that, and one of them decides exposure: the MCP stdio door gates + * every data verb on the copy's `enable.apiEnabled`, and an absent copy reads + * as "no declaration" (its gate falls open by design, parity with the HTTP + * gate), so an object whose API exposure was switched off after boot was + * still served, and a deleted one was still listed and described. + * + * The landing point is the copy's producer, on the seam every authoring door + * already funnels through (`onMetadataMutation`, which also replays on a + * cluster peer once its registry has converged), not the readers: the HTTP + * gate (`refuseUnexposedDataAction`), the AI tools and the MCP resources read + * the same service. A copy is refreshed IN PLACE, never dropped on save: an + * absent copy is the state that falls open. + * + * What it touches, and what it never does: + * + * - Only a copy of a RUNTIME-AUTHORED object: the registry's owner row for + * the name is tenant-authored (`isTenantAuthored`), or the copy already in + * the service says it is. A code package's copy is the code layer the + * protocol restores baselines from; it is sealed against runtime edits and + * kept by {@link bridgeManifestObjectsToMetadataService}, so it is left + * exactly as it is. + * - A refresh goes through `registerInMemory` (synchronous, no persistence, + * no announce), so it has landed before the authoring call returns and + * never writes a row through a writable loader. A service without it gets + * the bridge's own `register(..., { notify: false })`. + * - The one removal the service has, `unregister`, also deletes through its + * writable `datasource:` loaders, so it is called only when the mutation + * says `deleted` (the stored row is already gone) AND the registry no + * longer serves the name AND the copy is the runtime-authored one. + * Afterwards the registry is asked again: a re-creation that landed while + * the removal was awaiting its loaders has its copy put back. + * + * Armed on the kernel shape the bridge runs on (no `environmentId`): a + * project-scoped kernel's service is sourced from the artifact and holds no + * copy of the registry to keep current. + */ + private followRuntimeObjectCopy(ctx: PluginContext, evt: { name: string; state: string }): void { + if (this.environmentId !== undefined) return; + if (typeof evt?.name !== 'string' || evt.name === '') return; + // An async function runs synchronously up to its first `await`: the + // refresh of a served runtime object is done before this call returns. + void this.settleRuntimeObjectCopy(ctx, evt.name, evt.state).catch((e: unknown) => { + // Registry and service now disagree about an object while everything + // looks normal, and the exposure an author set is what the disagreement + // is about: an `error`, with the consequence and the way out. + ctx.logger.error( + `[ObjectQLPlugin] the metadata service's copy of object '${evt.name}' could not be brought up to date after it was ` + + 'saved or deleted: the registry, the data door and REST already serve the new definition, but readers of the ' + + 'metadata service (the MCP stdio door\'s exposure gate and object listing among them) keep the previous one, ' + + 'and nothing retries. Fix: restart this process (the boot bridge re-copies every object); if it recurs, ' + + 'check that the metadata service accepts in-memory registration.', + e instanceof Error ? e : new Error(String(e)), + { object: evt.name }, + ); + }); + } + + /** @see followRuntimeObjectCopy */ + private async settleRuntimeObjectCopy(ctx: PluginContext, name: string, state: string): Promise { + if (!this.ql?.registry) return; + let metadataService: IMetadataService | undefined; + try { + metadataService = ctx.getService('metadata'); + } catch { + return; // no metadata service on this kernel — there is no copy to keep + } + if (!metadataService) return; + + // The registry serves a runtime-authored object under this name: the copy + // IS its current definition. + if (await this.mirrorRuntimeObject(metadataService, name)) return; + + // It does not. Only a runtime-authored copy is this method's to change. + const existing = await metadataService.getObject(name); + if (!isTenantAuthored(existing)) return; + const served = this.currentObjectCopyBody(name); + if (served) { + // The registry now serves something else under the name (a code + // package's definition, once a runtime layer over it was removed): the + // copy follows what is served. + await this.putObjectCopy(metadataService, name, served); + return; + } + if (state !== 'deleted') return; + await metadataService.unregister('object', name, { notify: false }); + await this.mirrorRuntimeObject(metadataService, name); + } + + /** + * Put the registry's current definition of `name` into the service when the + * registry's owner row for it is tenant-authored. `false` = it is not (or the + * name is not registered), nothing was written. + */ + private async mirrorRuntimeObject(metadataService: IMetadataService, name: string): Promise { + const owner = this.ql?.registry.getObjectOwner(name); + if (!owner || !isTenantAuthored(owner.definition)) return false; + const body = this.currentObjectCopyBody(name); + if (!body) return false; + await this.putObjectCopy(metadataService, name, body); + return true; + } + + /** + * The definition the registry serves for `name`, shaped as the boot bridge + * copies it: the merged object, tagged with its owner's package the way + * `SchemaRegistry.getAllObjects` tags it. + */ + private currentObjectCopyBody(name: string): ServiceObject | undefined { + const registry = this.ql?.registry; + const current = registry?.getObject(name); + if (!registry || !current) return undefined; + const packageId = registry.getObjectOwner(name)?.packageId; + return { ...current, ...(packageId !== undefined ? { _packageId: packageId } : {}) } as ServiceObject; + } + + private putObjectCopy(metadataService: IMetadataService, name: string, body: ServiceObject): void | Promise { + if (typeof metadataService.registerInMemory === 'function') { + metadataService.registerInMemory('object', name, body); + return; + } + return metadataService.register('object', name, body, { notify: false }); + } + /** * [ADR-0130 D4] Register the objects of a stack's residual in the engine — * the half the metadata door cannot reach — so the data door serves what From 22bb362403cc46bccab5bb5a9ed8a7bc2b68a955 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:32:44 +0000 Subject: [PATCH 2/9] test(objectql): pin the metadata service's copy of a runtime-authored object after boot Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- ...lugin-runtime-object-metadata-copy.test.ts | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts diff --git a/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts b/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts new file mode 100644 index 00000000000..cf9d7610740 --- /dev/null +++ b/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts @@ -0,0 +1,310 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `metadata` service's copy of a RUNTIME-AUTHORED object follows the + * registry after boot: re-saved, it says what the save said; created after + * boot, it exists; deleted, it is gone and no longer listed. + * + * ## The defect + * + * On a kernel with no `environmentId`, `ObjectQLPlugin.start()` copies every + * object the registry holds into the `metadata` service + * (`bridgeObjectsToMetadataService`) and nothing writes to that service again: + * a runtime save goes to `sys_metadata` and the registry, never the service. + * So the service answered the boot body for a re-saved object, answered + * NOTHING for an object created afterwards, and kept answering for a deleted + * one. Every reader of `getObject` / `listObjects` inherits that. One of them + * decides exposure: the MCP stdio door gates each data verb on the copy's + * `enable.apiEnabled` and reads an absent copy as "no declaration". Its + * door-level pin is `packages/qa/dogfood/test/mcp-stdio-runtime-object-exposure.test.ts`; + * this file pins the producer's side of it, which every other reader shares. + * + * ## Why a real kernel, with `MetadataPlugin` + * + * The copy lives in the `metadata` service `MetadataPlugin` provides (a + * `NodeMetadataManager`, `watch: false`), the boot half is `loadMetaFromDb`'s, + * and the authoring half is the real protocol's `saveMetaItem` / + * `deleteMetaItem`. A second kernel over the same store is the boot. + * + * ## The pins + * + * - toggled off after boot, the copy says `apiEnabled: false` BEFORE the save + * returns (a refresh through `registerInMemory` is synchronous), and the + * listing agrees; + * - created after boot, the object has a copy at once; + * - deleted, the copy and its listing entry are gone; + * - deleted and created again while the removal is still awaiting the + * service, the new object keeps its copy; + * - CONTROL: a code package's copy is left exactly as the bridge made it, by + * a runtime edit of another object and by a delete naming the package's own + * object (which restores the baseline and removes nothing), and a runtime + * object that stays exposed keeps saying so. + * + * The negative control (red with the follow removed) is a one-time ablation, + * cited in the PR, not a permanent case. + */ + +import { randomUUID } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { ObjectQL } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; + +type Stores = Map>>; + +/** In-memory driver, copied from `protocol-delete-boot-hydrated-object-heal.test.ts`; equality-only WHERE. */ +function makeMemoryDriver(stores: Stores) { + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + 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, after the filter, by presence (`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) { + const id = (data.id as string) ?? `r_${randomUUID()}`; + 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() {}, + async dropTable(object: string) { stores.delete(object); }, + }; + return driver; +} + +const NAME = 'qa_probe_gadget'; +const OTHER = 'qa_probe_other'; +const SHIPPED_PKG = 'com.qa.shipped'; +const SHIPPED = 'qa_shipped_widget'; +const SYSTEM = { context: { isSystem: true } } as any; + +/** A runtime object body; `label` tells the writes apart, `enable` is the exposure under test. */ +const objectBody = (name: string, label: string, enable?: Record) => ({ + name, + label, + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + }, + ...(enable ? { enable } : {}), +}); + +interface Booted { + kernel: ObjectKernel; + engine: ObjectQL; + protocol: any; + metadata: any; +} + +describe('the metadata service\'s copy of a runtime-authored object follows the registry after boot', () => { + const live: ObjectKernel[] = []; + const roots: string[] = []; + afterEach(async () => { + while (live.length) { + const kernel = live.pop()!; + if (kernel.getState() === 'running') await kernel.shutdown(); + } + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }); + }); + + /** + * One boot over `stores`: `MetadataPlugin` provides the `metadata` service + * (an empty temp root, so it scans nothing), and `ObjectQLPlugin` with no + * `environmentId` hydrates `sys_metadata` and runs the boot bridge. + * `beforeBoot` registers what a code package ships, ahead of the bridge. + */ + async function boot(stores: Stores, beforeBoot?: (engine: ObjectQL) => void): Promise { + // `logger.level` is the real config key, and gracefulShutdown:false keeps + // process signal handlers out of vitest's fork workers (#4250). + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + live.push(kernel); + const root = mkdtempSync(join(tmpdir(), 'os-23661-')); + roots.push(root); + const engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(stores), true); + await kernel.use(new MetadataPlugin({ watch: false, rootDir: root })); + await kernel.use(new ObjectQLPlugin({ ql: engine })); + beforeBoot?.(engine); + await kernel.bootstrap(); + return { + kernel, + engine, + protocol: kernel.getService('protocol'), + metadata: kernel.getService('metadata'), + }; + } + + const save = (k: Booted, name: string, label: string, enable?: Record) => + k.protocol.saveMetaItem({ type: 'object', name, item: objectBody(name, label, enable) }); + + /** The copy as the service answers it, and the entry the listing carries for the name. */ + async function copyOf(k: Booted, name: string) { + const listed = ((await k.metadata.listObjects()) as Array<{ name?: string }>).find((o) => o?.name === name); + return { copy: await k.metadata.getObject(name), listed }; + } + + /** The object saved exposed, two boots ago: the second kernel hydrates it and the bridge copies it. */ + async function bootHydrated(stores: Stores, beforeBoot?: (engine: ObjectQL) => void): Promise { + const k1 = await boot(stores, beforeBoot); + expect((await save(k1, NAME, 'Gadget', { apiEnabled: true })).success).toBe(true); + await k1.kernel.shutdown(); + + const k2 = await boot(stores, beforeBoot); + // The defect's ingredient: the boot bridge's copy of the hydrated row. + // Without it the pins below would pass for an empty reason. + expect(await k2.metadata.getObject(NAME)) + .toMatchObject({ label: 'Gadget', enable: { apiEnabled: true }, _provenance: 'org' }); + return k2; + } + + it('toggled off after boot: the copy says so before the save returns, and so does the listing', async () => { + const stores: Stores = new Map(); + const k = await bootHydrated(stores); + + expect((await save(k, NAME, 'Gadget Q', { apiEnabled: false })).success).toBe(true); + + // No wait: the refresh is synchronous, so it landed before `save` resolved. + const { copy, listed } = await copyOf(k, NAME); + expect(copy).toMatchObject({ label: 'Gadget Q', enable: { apiEnabled: false }, _provenance: 'org' }); + expect(listed).toMatchObject({ label: 'Gadget Q', enable: { apiEnabled: false } }); + // …which is what the registry the data door dispatches on says. + expect(k.engine.registry.getObject(NAME)).toMatchObject({ label: 'Gadget Q', enable: { apiEnabled: false } }); + }); + + it('created after boot: the object has a copy at once, carrying its declaration (it had none)', async () => { + const stores: Stores = new Map(); + const k = await boot(stores); + expect(await k.metadata.getObject(NAME)).toBeUndefined(); + + expect((await save(k, NAME, 'Gadget', { apiEnabled: false })).success).toBe(true); + + const { copy, listed } = await copyOf(k, NAME); + expect(copy).toMatchObject({ label: 'Gadget', enable: { apiEnabled: false }, _provenance: 'org' }); + expect(listed).toBeDefined(); + }); + + it('deleted: the copy is gone and the listing no longer names the object', async () => { + const stores: Stores = new Map(); + const k = await bootHydrated(stores); + + const del = await k.protocol.deleteMetaItem({ type: 'object', name: NAME }); + + expect(del).toMatchObject({ success: true, reset: true }); + expect(k.engine.registry.getObject(NAME)).toBeUndefined(); + // The removal awaits the service, so it settles after the delete answers. + await vi.waitFor(async () => { + const { copy, listed } = await copyOf(k, NAME); + expect(copy).toBeUndefined(); + expect(listed).toBeUndefined(); + }); + }); + + it('deleted, then created again while the removal is still awaiting the service: the new object keeps its copy', async () => { + const stores: Stores = new Map(); + const k = await bootHydrated(stores); + // Hold the removal where a loader round trip would: at the service's `unregister`. + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const unregister = k.metadata.unregister.bind(k.metadata); + const unregistered: string[] = []; + k.metadata.unregister = async (...args: unknown[]) => { + unregistered.push(String(args[1])); + await gate; + return unregister(...args); + }; + + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + await vi.waitFor(() => expect(unregistered).toEqual([NAME])); + expect((await save(k, NAME, 'Gadget Reborn', { apiEnabled: false })).success).toBe(true); + // Released: the removal now drops the in-memory entry the re-creation just wrote. + release(); + + await vi.waitFor(async () => { + expect((await copyOf(k, NAME)).copy) + .toMatchObject({ label: 'Gadget Reborn', enable: { apiEnabled: false } }); + }); + }); + + it('CONTROL: a code package\'s copy is left as the bridge made it, and a runtime object that stays exposed keeps saying so', async () => { + const stores: Stores = new Map(); + const k = await bootHydrated(stores, (engine) => { + engine.registerApp({ + id: SHIPPED_PKG, name: SHIPPED_PKG, version: '1.0.0', type: 'app', + objects: [objectBody(SHIPPED, 'Shipped')], + }); + }); + const shipped = await k.metadata.getObject(SHIPPED); + expect(shipped).toMatchObject({ label: 'Shipped', _packageId: SHIPPED_PKG, _provenance: 'package' }); + expect((await save(k, OTHER, 'Other', { apiEnabled: true })).success).toBe(true); + + // A runtime edit, a runtime delete and a delete naming the package's own object. + expect((await save(k, NAME, 'Gadget Q', { apiEnabled: false })).success).toBe(true); + expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect(await k.protocol.deleteMetaItem({ type: 'object', name: SHIPPED })) + .toMatchObject({ success: true, reset: false }); + await vi.waitFor(async () => expect((await copyOf(k, NAME)).copy).toBeUndefined()); + + // The very entry the bridge wrote: same object, not an equal one. + expect(await k.metadata.getObject(SHIPPED)).toBe(shipped); + expect(k.engine.registry.getObject(SHIPPED)).toMatchObject({ label: 'Shipped' }); + // The runtime object nobody turned off still says it is exposed. + expect((await copyOf(k, OTHER)).copy).toMatchObject({ label: 'Other', enable: { apiEnabled: true } }); + }); +}); From 41e5babde364ddde3fd9b5107a1ee724c2818202 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:34:12 +0000 Subject: [PATCH 3/9] test(objectql): keep the CONTROL independent of the follow it guards Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- ...lugin-runtime-object-metadata-copy.test.ts | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts b/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts index cf9d7610740..043e8a9edd6 100644 --- a/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts +++ b/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts @@ -32,13 +32,14 @@ * returns (a refresh through `registerInMemory` is synchronous), and the * listing agrees; * - created after boot, the object has a copy at once; - * - deleted, the copy and its listing entry are gone; + * - deleted, the copy and its listing entry are gone, and a code package's + * copy is the very entry the bridge wrote; * - deleted and created again while the removal is still awaiting the * service, the new object keeps its copy; - * - CONTROL: a code package's copy is left exactly as the bridge made it, by - * a runtime edit of another object and by a delete naming the package's own - * object (which restores the baseline and removes nothing), and a runtime - * object that stays exposed keeps saying so. + * - CONTROL (green with the follow removed, by design): a code package's copy + * is left exactly as the bridge made it by runtime edits and by a delete + * naming the package's own object (which restores the baseline and removes + * nothing), and a runtime object that stays exposed keeps saying so. * * The negative control (red with the follow removed) is a one-time ablation, * cited in the PR, not a permanent case. @@ -200,6 +201,14 @@ describe('the metadata service\'s copy of a runtime-authored object follows the return { copy: await k.metadata.getObject(name), listed }; } + /** What a code package ships, registered ahead of the bridge. */ + const withShippedPackage = (engine: ObjectQL) => { + engine.registerApp({ + id: SHIPPED_PKG, name: SHIPPED_PKG, version: '1.0.0', type: 'app', + objects: [objectBody(SHIPPED, 'Shipped')], + }); + }; + /** The object saved exposed, two boots ago: the second kernel hydrates it and the bridge copies it. */ async function bootHydrated(stores: Stores, beforeBoot?: (engine: ObjectQL) => void): Promise { const k1 = await boot(stores, beforeBoot); @@ -240,9 +249,11 @@ describe('the metadata service\'s copy of a runtime-authored object follows the expect(listed).toBeDefined(); }); - it('deleted: the copy is gone and the listing no longer names the object', async () => { + it('deleted: the copy is gone and the listing no longer names the object, and a code package\'s copy is the one it was', async () => { const stores: Stores = new Map(); - const k = await bootHydrated(stores); + const k = await bootHydrated(stores, withShippedPackage); + const shipped = await k.metadata.getObject(SHIPPED); + expect(shipped).toMatchObject({ label: 'Shipped', _packageId: SHIPPED_PKG, _provenance: 'package' }); const del = await k.protocol.deleteMetaItem({ type: 'object', name: NAME }); @@ -254,6 +265,8 @@ describe('the metadata service\'s copy of a runtime-authored object follows the expect(copy).toBeUndefined(); expect(listed).toBeUndefined(); }); + // The very entry the bridge wrote: same object, not an equal one. + expect(await k.metadata.getObject(SHIPPED)).toBe(shipped); }); it('deleted, then created again while the removal is still awaiting the service: the new object keeps its copy', async () => { @@ -284,27 +297,25 @@ describe('the metadata service\'s copy of a runtime-authored object follows the it('CONTROL: a code package\'s copy is left as the bridge made it, and a runtime object that stays exposed keeps saying so', async () => { const stores: Stores = new Map(); - const k = await bootHydrated(stores, (engine) => { - engine.registerApp({ - id: SHIPPED_PKG, name: SHIPPED_PKG, version: '1.0.0', type: 'app', - objects: [objectBody(SHIPPED, 'Shipped')], - }); - }); + const k = await bootHydrated(stores, withShippedPackage); const shipped = await k.metadata.getObject(SHIPPED); expect(shipped).toMatchObject({ label: 'Shipped', _packageId: SHIPPED_PKG, _provenance: 'package' }); expect((await save(k, OTHER, 'Other', { apiEnabled: true })).success).toBe(true); - // A runtime edit, a runtime delete and a delete naming the package's own object. + // Runtime edits, and a delete naming the package's own object (no stored + // row: it restores the baseline and removes nothing). expect((await save(k, NAME, 'Gadget Q', { apiEnabled: false })).success).toBe(true); - expect((await k.protocol.deleteMetaItem({ type: 'object', name: NAME })).success).toBe(true); + expect((await save(k, OTHER, 'Other Q', { apiEnabled: true })).success).toBe(true); expect(await k.protocol.deleteMetaItem({ type: 'object', name: SHIPPED })) .toMatchObject({ success: true, reset: false }); - await vi.waitFor(async () => expect((await copyOf(k, NAME)).copy).toBeUndefined()); + // Nothing is expected to change, so there is no signal to wait for: a + // bounded quiet period lets any change the follow would wrongly make land. + await new Promise((resolve) => setTimeout(resolve, 150)); // The very entry the bridge wrote: same object, not an equal one. expect(await k.metadata.getObject(SHIPPED)).toBe(shipped); expect(k.engine.registry.getObject(SHIPPED)).toMatchObject({ label: 'Shipped' }); // The runtime object nobody turned off still says it is exposed. - expect((await copyOf(k, OTHER)).copy).toMatchObject({ label: 'Other', enable: { apiEnabled: true } }); + expect((await copyOf(k, OTHER)).copy).toMatchObject({ label: 'Other Q', enable: { apiEnabled: true } }); }); }); From 796abb2fd7f271b59a729734b3858abf4cda3072 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:35:25 +0000 Subject: [PATCH 4/9] test(objectql): hydrate the exposed control object at boot Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../src/plugin-runtime-object-metadata-copy.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts b/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts index 043e8a9edd6..18f9e20bd69 100644 --- a/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts +++ b/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts @@ -213,6 +213,7 @@ describe('the metadata service\'s copy of a runtime-authored object follows the async function bootHydrated(stores: Stores, beforeBoot?: (engine: ObjectQL) => void): Promise { const k1 = await boot(stores, beforeBoot); expect((await save(k1, NAME, 'Gadget', { apiEnabled: true })).success).toBe(true); + expect((await save(k1, OTHER, 'Other', { apiEnabled: true })).success).toBe(true); await k1.kernel.shutdown(); const k2 = await boot(stores, beforeBoot); @@ -220,6 +221,8 @@ describe('the metadata service\'s copy of a runtime-authored object follows the // Without it the pins below would pass for an empty reason. expect(await k2.metadata.getObject(NAME)) .toMatchObject({ label: 'Gadget', enable: { apiEnabled: true }, _provenance: 'org' }); + expect(await k2.metadata.getObject(OTHER)) + .toMatchObject({ label: 'Other', enable: { apiEnabled: true }, _provenance: 'org' }); return k2; } @@ -300,7 +303,6 @@ describe('the metadata service\'s copy of a runtime-authored object follows the const k = await bootHydrated(stores, withShippedPackage); const shipped = await k.metadata.getObject(SHIPPED); expect(shipped).toMatchObject({ label: 'Shipped', _packageId: SHIPPED_PKG, _provenance: 'package' }); - expect((await save(k, OTHER, 'Other', { apiEnabled: true })).success).toBe(true); // Runtime edits, and a delete naming the package's own object (no stored // row: it restores the baseline and removes nothing). @@ -316,6 +318,6 @@ describe('the metadata service\'s copy of a runtime-authored object follows the expect(await k.metadata.getObject(SHIPPED)).toBe(shipped); expect(k.engine.registry.getObject(SHIPPED)).toMatchObject({ label: 'Shipped' }); // The runtime object nobody turned off still says it is exposed. - expect((await copyOf(k, OTHER)).copy).toMatchObject({ label: 'Other Q', enable: { apiEnabled: true } }); + expect((await copyOf(k, OTHER)).copy).toMatchObject({ enable: { apiEnabled: true } }); }); }); From 5d8a1d5a47a8532121e4d962093046ee4fa4502a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:41:01 +0000 Subject: [PATCH 5/9] test(dogfood): pin the MCP stdio door against a runtime object's current exposure Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../mcp-stdio-runtime-object-exposure.test.ts | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 packages/qa/dogfood/test/mcp-stdio-runtime-object-exposure.test.ts diff --git a/packages/qa/dogfood/test/mcp-stdio-runtime-object-exposure.test.ts b/packages/qa/dogfood/test/mcp-stdio-runtime-object-exposure.test.ts new file mode 100644 index 00000000000..c7a336fbdfb --- /dev/null +++ b/packages/qa/dogfood/test/mcp-stdio-runtime-object-exposure.test.ts @@ -0,0 +1,372 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The MCP stdio door follows a runtime-authored object's CURRENT definition: + * an object whose API exposure is switched off after boot is refused by every + * data verb, an object created after boot honours its own declaration from the + * first call, and a deleted one is neither listed nor described. + * + * ## The defect + * + * The stdio door gates every data verb on the `metadata` service's copy of the + * object (`enforceApiExposure`: `getObject(name).enable.apiEnabled`) and lists + * and describes from the same service. On a kernel whose `ObjectQLPlugin` has no + * `environmentId`, that copy was made once at boot (`bridgeObjectsToMetadataService`) + * and no authoring door ever wrote to it again. Measured on this file's kernel + * before the fix: + * + * - an object saved with `apiEnabled: false` AFTER boot has no copy, and an + * absent copy reads as "no declaration" (the gate falls open by design, as + * the HTTP gate does), so `query_records` and `create_record` were SERVED + * — the create wrote a row; + * - a boot-hydrated object re-saved with `apiEnabled: false` kept its boot + * copy (`apiEnabled: true`), with the same result; + * - a deleted object stayed in `list_objects` and `describe_object` (the engine + * refused its data verbs, which is the registry's own answer). + * + * The producer is the boot bridge's copy, kept current now by + * `ObjectQLPlugin.followRuntimeObjectCopy`; its own pins, over the service's + * answers, are `packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts`. + * This file pins the door. + * + * ## What is real, and the one seam that is not + * + * The kernel is real (`ObjectKernel` with `MetadataPlugin` and an + * `ObjectQLPlugin` with no `environmentId`), and so are the `metadata` service, + * the engine and its registry, the protocol's `saveMetaItem` / `deleteMetaItem`, + * and the `MCPServerPlugin` whose `start()` builds the stdio data bridge. The + * session is the plugin's own server over an in-process transport speaking the + * JSON-RPC a stdio client speaks (a process cannot give a test its stdin). + * + * The one seam faked is the key row the stdio principal resolves from: the + * engine is wrapped so `sys_api_key` answers one non-revoked key and any other + * platform table the principal chain reads that this kernel does not register + * answers empty. Every other read goes to the real engine. + * + * The negative control (red with the producer's follow removed) is a one-time + * ablation, cited in the PR, not a permanent case. + */ + +import { randomUUID } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { MCPServerPlugin, MCPServerRuntime } from '@objectstack/mcp'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; + +type Stores = Map>>; + +/** In-memory driver, copied from the objectql suite's real-kernel pins; equality-only WHERE. */ +function makeMemoryDriver(stores: Stores) { + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + 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, after the filter, by presence (`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) { + const id = (data.id as string) ?? `r_${randomUUID()}`; + 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() {}, + async dropTable(object: string) { stores.delete(object); }, + }; + return driver; +} + +const GATED = 'qa_gated_gadget'; +const OPEN = 'qa_open_gadget'; +const SYSTEM = { context: { isSystem: true } } as never; + +/** A runtime object body; `enable` is the exposure under test. */ +const objectBody = (name: string, label: string, enable: Record) => ({ + name, + label, + // [#8310] The runtime object door requires an authored OWD. + sharingModel: 'private', + fields: { name: { name: 'name', type: 'text', label: 'Name' } }, + enable, +}); + +/** What a tool call answered: `isError`, and the JSON the tool returned (or its bare error text). */ +interface ToolAnswer { + isError: boolean; + text: string; + json?: any; +} + +/** An in-process MCP transport: the server's replies are matched back to the request that asked. */ +function inProcessTransport() { + const waiting = new Map void>(); + let nextId = 1; + const transport: any = { + onmessage: undefined, + onclose: undefined, + onerror: undefined, + async start() {}, + async close() { transport.onclose?.(); }, + async send(message: any) { + const resolve = typeof message?.id === 'number' ? waiting.get(message.id) : undefined; + if (resolve) { waiting.delete(message.id); resolve(message); } + }, + }; + const rpc = (method: string, params?: unknown) => new Promise((resolve, reject) => { + const id = nextId++; + const giveUp = setTimeout(() => reject(new Error(`no answer to "${method}" (id ${id}) within 10s`)), 10_000); + waiting.set(id, (frame) => { clearTimeout(giveUp); resolve(frame); }); + transport.onmessage?.({ jsonrpc: '2.0', id, method, ...(params ? { params } : {}) }); + }); + return { transport, rpc, notify: (method: string) => transport.onmessage?.({ jsonrpc: '2.0', method }) }; +} + +describe('the MCP stdio door follows a runtime-authored object\'s current definition', () => { + const live: ObjectKernel[] = []; + const roots: string[] = []; + const originalKey = process.env.OS_MCP_STDIO_API_KEY; + + beforeEach(() => { + process.env.OS_MCP_STDIO_API_KEY = 'osk_mcp_stdio_runtime_object_exposure'; + }); + afterEach(async () => { + if (originalKey === undefined) delete process.env.OS_MCP_STDIO_API_KEY; + else process.env.OS_MCP_STDIO_API_KEY = originalKey; + vi.restoreAllMocks(); + while (live.length) { + const kernel = live.pop()!; + if (kernel.getState() === 'running') await kernel.shutdown(); + } + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }); + }); + + interface Booted { + kernel: ObjectKernel; + engine: ObjectQL; + protocol: any; + call(name: string, args: Record): Promise; + } + + /** + * One boot over `stores`, then a stdio session over it: `MetadataPlugin` provides + * the `metadata` service, `ObjectQLPlugin` with no `environmentId` hydrates + * `sys_metadata` and runs the boot bridge, and `MCPServerPlugin` builds the + * stdio data bridge over this kernel's own services. + */ + async function boot(stores: Stores): Promise { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + live.push(kernel); + const root = mkdtempSync(join(tmpdir(), 'os-mcp-stdio-exposure-')); + roots.push(root); + const engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(stores), true); + await kernel.use(new MetadataPlugin({ watch: false, rootDir: root })); + await kernel.use(new ObjectQLPlugin({ ql: engine })); + await kernel.bootstrap(); + + // The faked seam, and only it: the key row the stdio principal resolves from. + const ql = new Proxy(engine as any, { + get(target, prop, receiver) { + if (prop === 'find') { + return async (object: string, query?: unknown, options?: unknown) => { + if (object === 'sys_api_key') return [{ id: 'k1', user_id: 'usr_stdio', revoked: false }]; + if (object.startsWith('sys_') && object !== 'sys_metadata' && !target.registry.getObject(object)) return []; + return target.find(object, query, options); + }; + } + const value = Reflect.get(target, prop, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const services = new Map([ + ['metadata', kernel.getService('metadata')], + ['protocol', kernel.getService('protocol')], + ['objectql', ql], + ]); + const ctx: any = { + registerService: vi.fn((name: string, service: unknown) => services.set(name, service)), + getService: vi.fn((name: string) => { + if (!services.has(name)) throw new Error(`Service "${name}" not found`); + return services.get(name); + }), + replaceService: vi.fn(), + getServices: vi.fn(() => services), + hook: vi.fn(), + trigger: vi.fn(async () => {}), + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(() => ({})), + }; + const plugin = new MCPServerPlugin({ autoStart: true }); + await plugin.init(ctx); + const runtime = services.get('mcp') as MCPServerRuntime; + // `start()` would claim this process's real stdin/stdout. + vi.spyOn(runtime, 'start').mockResolvedValue(undefined); + await plugin.start(ctx); + + const session = inProcessTransport(); + await runtime.server.connect(session.transport as never); + await session.rpc('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'mcp-stdio-runtime-object-exposure', version: '0.0.0' }, + }); + session.notify('notifications/initialized'); + + return { + kernel, + engine, + protocol: kernel.getService('protocol'), + async call(name, args) { + const frame = await session.rpc('tools/call', { name, arguments: args }); + expect(frame.error, `tools/call ${name} failed at the protocol level`).toBeUndefined(); + const text = frame.result.content[0].text as string; + let json: any; + try { json = JSON.parse(text); } catch { /* a bare error sentence */ } + return { isError: frame.result.isError === true, text, json }; + }, + }; + } + + const save = (k: Booted, name: string, label: string, enable: Record) => + k.protocol.saveMetaItem({ type: 'object', name, item: objectBody(name, label, enable) }); + + /** The coded envelope a refused tool call carries. */ + const refusal = (answer: ToolAnswer) => (answer.isError ? { code: answer.json?.error?.code, status: answer.json?.error?.status } : undefined); + + /** Both objects saved exposed, two records each, then a second boot: the objects are hydrated and bridged. */ + async function bootHydrated(stores: Stores): Promise { + const k1 = await boot(stores); + expect((await save(k1, GATED, 'Gated', { apiEnabled: true })).success).toBe(true); + expect((await save(k1, OPEN, 'Open', { apiEnabled: true })).success).toBe(true); + for (const name of [GATED, OPEN]) { + await k1.engine.insert(name, { name: 'rec-1' }, SYSTEM); + await k1.engine.insert(name, { name: 'rec-2' }, SYSTEM); + } + await k1.kernel.shutdown(); + return boot(stores); + } + + const names = (answer: ToolAnswer) => (answer.json?.objects as Array<{ name: string }> | undefined)?.map((o) => o.name) ?? []; + + it('CONTROL: an exposed runtime object is listed, described, queried and written through the door', async () => { + const stores: Stores = new Map(); + const k = await bootHydrated(stores); + + const listed = await k.call('list_objects', {}); + expect(names(listed)).toEqual(expect.arrayContaining([GATED, OPEN])); + const described = await k.call('describe_object', { objectName: OPEN }); + expect(described.isError).toBe(false); + expect(described.json).toMatchObject({ name: OPEN, enableFeatures: { apiEnabled: true } }); + const queried = await k.call('query_records', { objectName: OPEN }); + expect(queried.isError).toBe(false); + expect(queried.json.records).toHaveLength(2); + const created = await k.call('create_record', { objectName: OPEN, data: { name: 'rec-3' } }); + expect(created.isError).toBe(false); + expect(stores.get(OPEN)?.size).toBe(3); + }); + + it('toggled off after boot: every data verb is refused, nothing is read or written, and the exposed neighbour keeps being served', async () => { + const stores: Stores = new Map(); + const k = await bootHydrated(stores); + expect((await k.call('query_records', { objectName: GATED })).isError).toBe(false); + + expect((await save(k, GATED, 'Gated', { apiEnabled: false })).success).toBe(true); + + const refused = { code: 'OBJECT_API_DISABLED', status: 404 }; + expect(refusal(await k.call('query_records', { objectName: GATED }))).toEqual(refused); + expect(refusal(await k.call('get_record', { objectName: GATED, recordId: 'any' }))).toEqual(refused); + expect(refusal(await k.call('create_record', { objectName: GATED, data: { name: 'leak-probe' } }))).toEqual(refused); + expect(refusal(await k.call('update_record', { objectName: GATED, recordId: 'any', data: { name: 'x' } }))).toEqual(refused); + expect(refusal(await k.call('delete_record', { objectName: GATED, recordId: 'any' }))).toEqual(refused); + // Refused before dispatch: the table holds exactly what it held. + expect(stores.get(GATED)?.size).toBe(2); + + // The neighbour nobody touched is still served, and still described as exposed. + const neighbour = await k.call('query_records', { objectName: OPEN }); + expect(neighbour.isError).toBe(false); + expect(neighbour.json.records).toHaveLength(2); + }); + + it('created after boot with exposure off: refused from the first call (it had no copy, so it was served)', async () => { + const stores: Stores = new Map(); + const k = await boot(stores); + expect((await save(k, GATED, 'Gated', { apiEnabled: false })).success).toBe(true); + await k.engine.insert(GATED, { name: 'rec-1' }, SYSTEM); + + expect(refusal(await k.call('query_records', { objectName: GATED }))).toEqual({ code: 'OBJECT_API_DISABLED', status: 404 }); + expect(refusal(await k.call('create_record', { objectName: GATED, data: { name: 'leak-probe' } }))) + .toEqual({ code: 'OBJECT_API_DISABLED', status: 404 }); + expect(stores.get(GATED)?.size).toBe(1); + }); + + it('deleted: it is neither listed nor described, and its data verbs are refused', async () => { + const stores: Stores = new Map(); + const k = await bootHydrated(stores); + expect(names(await k.call('list_objects', {}))).toContain(GATED); + + expect((await k.protocol.deleteMetaItem({ type: 'object', name: GATED })).success).toBe(true); + + // The removal awaits the metadata service, so it settles after the delete answers. + await vi.waitFor(async () => expect(names(await k.call('list_objects', {}))).not.toContain(GATED)); + const described = await k.call('describe_object', { objectName: GATED }); + expect(described).toMatchObject({ isError: true, text: expect.stringContaining('not found') }); + expect(refusal(await k.call('query_records', { objectName: GATED }))).toEqual({ code: 'OBJECT_NOT_FOUND', status: 404 }); + // The object nobody deleted is still listed. + expect(names(await k.call('list_objects', {}))).toContain(OPEN); + }); +}); From f65760d6bc1bcfacef73c13be55c9dca37f209f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:43:29 +0000 Subject: [PATCH 6/9] test(dogfood): take the key use stamp at the faked seam Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../test/mcp-stdio-runtime-object-exposure.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/qa/dogfood/test/mcp-stdio-runtime-object-exposure.test.ts b/packages/qa/dogfood/test/mcp-stdio-runtime-object-exposure.test.ts index c7a336fbdfb..40934a2fc20 100644 --- a/packages/qa/dogfood/test/mcp-stdio-runtime-object-exposure.test.ts +++ b/packages/qa/dogfood/test/mcp-stdio-runtime-object-exposure.test.ts @@ -39,9 +39,10 @@ * JSON-RPC a stdio client speaks (a process cannot give a test its stdin). * * The one seam faked is the key row the stdio principal resolves from: the - * engine is wrapped so `sys_api_key` answers one non-revoked key and any other - * platform table the principal chain reads that this kernel does not register - * answers empty. Every other read goes to the real engine. + * engine is wrapped so `sys_api_key` answers one non-revoked key (and takes its + * use stamp), and any other platform table the principal chain reads that this + * kernel does not register answers empty. Every other call goes to the real + * engine. * * The negative control (red with the producer's follow removed) is a one-time * ablation, cited in the PR, not a permanent case. @@ -222,6 +223,11 @@ describe('the MCP stdio door follows a runtime-authored object\'s current defini // The faked seam, and only it: the key row the stdio principal resolves from. const ql = new Proxy(engine as any, { get(target, prop, receiver) { + // The use stamp a resolved key takes (`last_used_at`) has no table here. + if (prop === 'update') { + return async (object: string, data: unknown, options?: unknown) => + object === 'sys_api_key' ? {} : target.update(object, data, options); + } if (prop === 'find') { return async (object: string, query?: unknown, options?: unknown) => { if (object === 'sys_api_key') return [{ id: 'k1', user_id: 'usr_stdio', revoked: false }]; From 591b669eb3e882dc2b423e86db2c7b2b148d54e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:44:00 +0000 Subject: [PATCH 7/9] chore(changeset): objectql keeps the metadata service's runtime-object copy current Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../23661-runtime-object-metadata-copy.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/23661-runtime-object-metadata-copy.md diff --git a/.changeset/23661-runtime-object-metadata-copy.md b/.changeset/23661-runtime-object-metadata-copy.md new file mode 100644 index 00000000000..442f466318b --- /dev/null +++ b/.changeset/23661-runtime-object-metadata-copy.md @@ -0,0 +1,22 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): the metadata service's copy of a runtime-authored object follows its saves and deletes after boot, so the MCP stdio door enforces an object's current API exposure and no longer lists a deleted object + +Clause-②: no + +On a kernel whose `ObjectQLPlugin` has no `environmentId` (the CLI host-config boot, the kernel factory, a control plane), the plugin copies every registry object into the `metadata` service once, at boot, and no authoring door wrote to that service afterwards. For a runtime-authored object (one saved through Studio, the REST metadata door or an AI author) the service's answer was therefore the boot body, or no body at all: + +- **An object saved after boot had no copy.** The MCP stdio door gates every data verb on the copy's `enable.apiEnabled` and reads an absent copy as no declaration, so `query_records`, `create_record` and the other verbs were served on an object saved with `apiEnabled: false`. The create wrote a row. +- **An object re-saved after boot kept its boot copy**, with the same result when its exposure was turned off. +- **A deleted object kept its copy**, so `list_objects` and `describe_object` still named it. Its data verbs were already refused by the engine. + +The copy now follows the registry on the same signal every authoring door already emits (`onMetadataMutation`, which also reaches a cluster peer once its registry has converged): + +- **A save refreshes the copy before the save returns**, through `registerInMemory` (synchronous, no persistence, no announce): the object's current `enable` is what the exposure gate reads. An object created after boot gets a copy. +- **A delete removes the copy**, once the stored row is gone and the registry no longer serves the name. If the object is created again while the removal is still running, the new copy is put back. +- **A code package's copy is never touched.** Only a copy of a runtime-authored object (the registry's owner row for the name is tenant-authored, or the copy already says so) follows the registry; the package copies the protocol restores baselines from stay exactly as the boot bridge made them. +- **A refresh that cannot land is logged at `error`**, naming the object, the consequence (readers of the metadata service keep the previous definition) and the way out. + +Every other reader of `IMetadataService.getObject` and `listObjects` on such a kernel sees the same current definition. The runtime dispatcher's exposure gate (`refuseUnexposedDataAction`, which the HTTP MCP door's data verbs run through) is one: it allowed an object saved with `apiEnabled: false` after boot, and refuses it now. A project-scoped kernel (one built with an `environmentId`) does not run the boot bridge and is unchanged. From da8d0047c452a6041e9ba83f9d10f4db38397993 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 14:26:07 +0000 Subject: [PATCH 8/9] chore(changeset): state only the verbs the walk measured Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/23661-runtime-object-metadata-copy.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/23661-runtime-object-metadata-copy.md b/.changeset/23661-runtime-object-metadata-copy.md index 442f466318b..8a9a8bb2063 100644 --- a/.changeset/23661-runtime-object-metadata-copy.md +++ b/.changeset/23661-runtime-object-metadata-copy.md @@ -8,8 +8,8 @@ Clause-②: no On a kernel whose `ObjectQLPlugin` has no `environmentId` (the CLI host-config boot, the kernel factory, a control plane), the plugin copies every registry object into the `metadata` service once, at boot, and no authoring door wrote to that service afterwards. For a runtime-authored object (one saved through Studio, the REST metadata door or an AI author) the service's answer was therefore the boot body, or no body at all: -- **An object saved after boot had no copy.** The MCP stdio door gates every data verb on the copy's `enable.apiEnabled` and reads an absent copy as no declaration, so `query_records`, `create_record` and the other verbs were served on an object saved with `apiEnabled: false`. The create wrote a row. -- **An object re-saved after boot kept its boot copy**, with the same result when its exposure was turned off. +- **An object saved after boot had no copy.** The MCP stdio door gates every data verb on the copy's `enable.apiEnabled` and reads an absent copy as no declaration, so `query_records` was served on an object saved with `apiEnabled: false`. +- **An object re-saved after boot kept its boot copy**, so when its exposure was turned off `query_records` and `create_record` were served on it, and the create wrote a row. - **A deleted object kept its copy**, so `list_objects` and `describe_object` still named it. Its data verbs were already refused by the engine. The copy now follows the registry on the same signal every authoring door already emits (`onMetadataMutation`, which also reaches a cluster peer once its registry has converged): From e55180423b73ccd293b94bbf3a99a204373089ca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:14:00 +0000 Subject: [PATCH 9/9] test(objectql): drop an unused constant from the runtime-object copy pins Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../objectql/src/plugin-runtime-object-metadata-copy.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts b/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts index 18f9e20bd69..6264f81c920 100644 --- a/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts +++ b/packages/objectql/src/plugin-runtime-object-metadata-copy.test.ts @@ -133,7 +133,6 @@ const NAME = 'qa_probe_gadget'; const OTHER = 'qa_probe_other'; const SHIPPED_PKG = 'com.qa.shipped'; const SHIPPED = 'qa_shipped_widget'; -const SYSTEM = { context: { isSystem: true } } as any; /** A runtime object body; `label` tells the writes apart, `enable` is the exposure under test. */ const objectBody = (name: string, label: string, enable?: Record) => ({