diff --git a/.changeset/eighty-poems-visit.md b/.changeset/eighty-poems-visit.md new file mode 100644 index 0000000000..13f3fbaa2c --- /dev/null +++ b/.changeset/eighty-poems-visit.md @@ -0,0 +1,22 @@ +--- +'@objectstack/objectql': patch +--- + +The boot-time action-governance audit now reaches a SCOPED `metadata` service. + +`ObjectQLPlugin.runGovernanceInventory` acquired its metadata plane with +`ctx.getService('metadata')`, which reads only the two synchronous service maps. +A composition that registers `metadata` with `ServiceLifecycle.SCOPED` mints its +instances into `PluginLoader.scopedServices` instead, so the call threw +`Service 'metadata' is async - use await` before `loadMany`, `loadManyKeyed`, +`loadDiagnosed` or `load` could run, the plugin swallowed the throw into "no +metadata plane at all", and the ADR-0110 D5 inventory reported that scope's +`action` declarations as absent — silently, because an empty declaration set is +indistinguishable from a plane that holds nothing. On such a kernel a handler the +router dispatches was reported as "registered handler with NO declaration … +REFUSED at dispatch". + +The plane is now resolved in the router's own order — `getServiceScoped('metadata', +environmentId)` first, then the synchronous lookup — so the audit holds the same +instance `HttpDispatcher.resolveService` hands the router. Statically registered +planes (every shipped composition today) resolve to the same object as before. diff --git a/packages/objectql/src/action-governance.ts b/packages/objectql/src/action-governance.ts index 752ed5414d..720a6fa15c 100644 --- a/packages/objectql/src/action-governance.ts +++ b/packages/objectql/src/action-governance.ts @@ -486,20 +486,31 @@ function fingerprint(r: ReturnType): string * identity the store holds each row by (#14205), so a row whose body * carries no `name` is a declaration here exactly as it is to the router. * - * ## ⚠️ The boundary this audit does NOT cross, stated so nobody re-discovers it - * - * This runs at boot, OUTSIDE any request scope, so it reads the metadata - * service its host can hand it — `ctx.getService('metadata')`. If a - * composition ever registers `metadata` with a SCOPED lifecycle, that - * accessor cannot reach the per-scope instance at all (it throws before any - * read method runs), the audit falls back to the sources it does have, and it - * may then report a handler the router serves from a scoped plane. That is a - * BOUNDARY of a boot-time audit, not a defect in these reads, and it is not - * reachable today: no shipped composition registers `metadata` as SCOPED - * (`packages/metadata/src/plugin.ts` registers a static instance). Making a - * boot-time audit reach a request-scoped service is a separate product - * change, tracked on its own card — ⛔ do not "fix" it by loosening what the - * reads below claim. + * ## [#15252] The SCOPED plane, and the boundary that is left + * + * This runs at boot, OUTSIDE any request scope, so which plane it reads is the + * caller's decision, not this function's. That decision used to be + * `ctx.getService('metadata')` and nothing else — an accessor that reads only + * the synchronous service maps, so against a composition registering + * `metadata` with a SCOPED lifecycle it threw before any read method ran, the + * caller swallowed the throw, and this inventory audited a scope's handlers + * with that scope's declarations missing. Keying an enumeration cannot help a + * caller that never obtained the object to enumerate, and neither can a + * by-name rung: the fault sat upstream of every source below. + * + * `ObjectQLPlugin.resolveGovernanceMetadataService` is where that is settled + * now, by resolving the plane in the router's own order — scoped first, under + * the kernel's declared `environmentId`, then the synchronous lookup — so the + * audit holds the same instance `HttpDispatcher.resolveService` hands the + * router. ⛔ It is still not this function's job: a caller that hands over no + * plane still gets an inventory that audits the sources it does have, and that + * degradation must stay quiet rather than be papered over here. + * + * ⚠️ What remains a BOUNDARY, so it is not re-filed as a defect: a kernel + * serving several environments at once declares no single `environmentId`, so + * a boot-time audit has no scope to name and reads the non-scoped plane. + * Auditing per environment is a different inventory with a different + * lifecycle. ⛔ Do not "fix" that one by loosening what the reads below claim. */ export async function runActionGovernanceInventory(args: { registered: Array<{ objectName: string; actionName: string; package?: string }>; diff --git a/packages/objectql/src/plugin-governance-scoped-metadata.test.ts b/packages/objectql/src/plugin-governance-scoped-metadata.test.ts new file mode 100644 index 0000000000..8a895bb8dc --- /dev/null +++ b/packages/objectql/src/plugin-governance-scoped-metadata.test.ts @@ -0,0 +1,395 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15252] The boot-time action-governance audit reaches a SCOPED metadata + * service — the C4 cell #14423's ruling left open. + * + * ## What was broken + * + * `ObjectQLPlugin.runGovernanceInventory` acquired its metadata plane with + * `ctx.getService('metadata')`. That accessor reads only the two SYNCHRONOUS + * service maps — the kernel's own `services` and + * `PluginLoader.getServiceInstance` over `serviceInstances` — while a + * `ServiceLifecycle.SCOPED` registration mints its instances into + * `scopedServices`, keyed by scope id. So against a scoped `metadata` the call + * threw `Service 'metadata' is async - use await` BEFORE `loadMany`, + * `loadManyKeyed`, `loadDiagnosed` or `load` could run, the plugin swallowed + * the throw into "no metadata plane at all", and the audit reported that + * scope's declarations as absent. Silently: an empty declaration set is + * byte-identical to a plane that holds nothing. + * + * The router had no such gap — `HttpDispatcher.resolveService` asks + * `defaultKernel.getServiceAsync(name, scopeId)` first — so the two sides + * disagreed about what exists, which is the one thing this audit is for. + * + * ## Why every assertion here is about what the audit SAYS + * + * The pre-fix failure is silent, so "the audit did not throw" is exactly the + * state that already existed and pins nothing. Each case below reads the + * audit's OUTPUT: the scoped declaration named in a warning, the scoped + * declaration clearing a handler that would otherwise be accused, and the + * factory log proving which scope was resolved. + * + * ## Real vs. doubled + * + * Real: `ObjectKernel` + `PluginLoader` scoping and the `PluginContext` it + * hands plugins (`@objectstack/core`), `ObjectQL` and `ObjectQLPlugin` (this + * package), `MetadataManager` + `DatabaseLoader` (`@objectstack/metadata`). + * The audit's service acquisition is therefore the real accessor, not a + * transcription of it — which is the whole subject. + * + * Doubled: the row store under `DatabaseLoader` (a `sys_metadata`-shaped + * read-only engine, the same double shape + * `packages/runtime/src/action-governance-scope-divergence.test.ts` uses). + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel, ServiceLifecycle } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import { MetadataManager, DatabaseLoader } from '@objectstack/metadata'; +import type { MetadataLoader } from '@objectstack/metadata'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectQL } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; + +/** The declaration under test, and the handler key it addresses. */ +const ACTION = 'promote_lead'; +/** `GLOBAL_ACTION_OBJECT_KEY` — the object-less action's owning key. */ +const OBJECT_KEY = 'global'; +/** The environment this kernel declares it serves. */ +const ENV = 'env_a'; + +/** + * A `sys_metadata`-shaped READ-ONLY engine over a fixed row array. + * + * Read-only on purpose: `DatabaseLoader`'s read paths call `find` / `findOne` + * / `count` and nothing else, so a double that also declared `update` / + * `delete` would be declaring dispatch surface this fixture never exercises. + * + * The WHERE matcher REFUSES what it does not implement rather than reading a + * combinator as a field name (`check:where-matcher`'s conforming shape), and + * `find` applies the caller's `limit` by PRESENCE + * (`check:objectql-double-limit`) — a double that ignores it answers more rows + * than the real engine would. + */ +function readEngine(rows: Array>) { + const matches = (r: Record, w: Record) => + Object.entries(w).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`readEngine: unsupported WHERE combinator '${k}'`); + if (v !== null && typeof v === 'object') throw new Error(`readEngine: unsupported WHERE operator on '${k}'`); + return r[k] === v; + }); + return { + async find(table: string, q: any) { + void table; + const hits = rows.filter((r) => matches(r, q?.where ?? {})); + return typeof q?.limit === 'number' ? hits.slice(0, q.limit) : hits; + }, + async findOne(table: string, q: any) { + // `check:engine-double-contract`: a fake whose findOne is looser + // than `ObjectQL.findOne` is how a dead route ships with a green + // suite. Route the predicate through the shared assertion. + assertEngineFindOnePredicate(table, q); + return rows.find((r) => matches(r, q?.where ?? {})) ?? null; + }, + async count(_table: string, q: any) { + return rows.filter((r) => matches(r, q?.where ?? {})).length; + }, + }; +} + +/** A `sys_metadata` row: identity in the `name` COLUMN, body in `metadata`. */ +function row(name: string, body: Record) { + return { + id: `md_${name}`, + name, + type: 'action', + namespace: 'default', + scope: 'platform', + state: 'active', + version: 1, + metadata: JSON.stringify(body), + }; +} + +/** A real `MetadataManager` over a real `DatabaseLoader` over `rows`. */ +function planeOver(rows: Array>): MetadataManager { + const mgr = new MetadataManager({}); + mgr.registerLoader(new DatabaseLoader({ + engine: readEngine(rows) as any, + trackHistory: false, + cache: { enabled: false }, + } as any) as unknown as MetadataLoader); + return mgr; +} + +/** The declaration body: a `script` action with no `body` and no `target`. */ +const declaration = () => ({ name: ACTION, type: 'script', label: 'Promote lead' }); + +interface Recorded { + level: 'debug' | 'info' | 'warn' | 'error'; + message: string; + args: unknown[]; +} + +/** The four levels the audit and the engine write through, and nothing else. */ +type RecordingLogger = Record void>; + +function recordingLogger() { + const records: Recorded[] = []; + const push = (level: Recorded['level']) => (message: string, ...args: unknown[]) => + void records.push({ level, message: String(message), args }); + return { + records, + logger: { debug: push('debug'), info: push('info'), warn: push('warn'), error: push('error') }, + at(level: Recorded['level']) { + return records.filter((r) => r.level === level); + }, + }; +} + +const UNDECLARED = /registered handlers with NO declaration/; +const UNBOUND = /declared script actions with NO handler/; + +/** Did the audit accuse `OBJECT_KEY:ACTION` of being a handler with no declaration? */ +function accused(rec: ReturnType): boolean { + return rec.at('warn').some((r) => + UNDECLARED.test(r.message) && + ((r.args[0] as { handlers?: string[] } | undefined)?.handlers ?? []).includes(`${OBJECT_KEY}:${ACTION}`)); +} + +/** Did the audit REPORT `OBJECT_KEY:ACTION` as a declaration nothing binds? */ +function reportedUnbound(rec: ReturnType): boolean { + return rec.at('warn').some((r) => + UNBOUND.test(r.message) && + ((r.args[0] as { actions?: string[] } | undefined)?.actions ?? []).includes(`${OBJECT_KEY}:${ACTION}`)); +} + +/** + * Captures the REAL `PluginContext` the kernel hands its plugins. + * + * The audit's service acquisition is the subject here, so the context it runs + * on has to be the kernel's own — `context.getService` and + * `context.getServiceScoped` as `ObjectKernel` builds them, over the same + * `PluginLoader` the dispatcher's `getServiceAsync` reads. Nothing about them + * is re-spelled in this file. + */ +class ContextProbe implements Plugin { + name = 'test.governance-scoped-metadata.context-probe'; + version = '1.0.0'; + captured: PluginContext | undefined; + init = async (ctx: PluginContext): Promise => { + this.captured = ctx; + }; +} + +/** + * The kernel's real context with ONLY the logger swapped, so the audit's + * warnings can be read back. `Object.create` rather than a rebuild: every + * accessor still resolves to the kernel's own through the prototype chain, so + * this substitutes nothing the test is measuring. + */ +function auditContext(real: PluginContext, logger: RecordingLogger): PluginContext { + return Object.assign(Object.create(real as object) as PluginContext, { logger }); +} + +/** + * Compose the kernel, the plane and the plugin, and hand back everything the + * cases read. + * + * ⚠️ `registerMetadata` runs BEFORE `bootstrap()`, and it has to: the kernel + * pre-injects `createMemoryMetadata` for any `metadata` slot still empty at + * Phase 2 (`preInjectCoreFallbacks`, `metadata: 'core'`), so a plane registered + * afterwards would be auditing beside a fallback rather than instead of one — + * and a real composition registers its plane in a plugin's `init()`, which is + * Phase 1. `data` is the kernel's one `required` slot and the engine is what + * fills it in every real composition (`ctx.registerService('data', this.ql)`), + * so it is filled with the engine itself rather than a stand-in. + */ +async function compose(opts: { + handler: boolean; + environmentId?: string; + registerMetadata: (kernel: ObjectKernel) => void; +}): Promise<{ + kernel: ObjectKernel; + rec: ReturnType; + plugin: ObjectQLPlugin; + ctx: PluginContext; +}> { + const rec = recordingLogger(); + const engine = new ObjectQL({ logger: rec.logger } as any); + if (opts.handler) engine.registerAction(OBJECT_KEY, ACTION, async () => 1); + + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + kernel.registerService('data', engine); + opts.registerMetadata(kernel); + + const probe = new ContextProbe(); + await kernel.use(probe); + await kernel.bootstrap(); + if (!probe.captured) throw new Error('the kernel handed the probe no PluginContext'); + + const plugin = new ObjectQLPlugin({ ql: engine, environmentId: opts.environmentId }); + return { kernel, rec, plugin, ctx: auditContext(probe.captured, rec.logger) }; +} + +/** Run the audit exactly as `kernel:ready` runs it. */ +async function runAudit(plugin: ObjectQLPlugin, ctx: PluginContext): Promise { + await (plugin as unknown as { runGovernanceInventory(c: PluginContext): Promise }) + .runGovernanceInventory(ctx); +} + +describe('#15252 — the boot-time governance audit reads the SCOPED metadata plane', () => { + let live: ObjectKernel | undefined; + + afterEach(async () => { + if (live && live.getState() === 'running') await live.shutdown(); + live = undefined; + }); + + /** + * A scoped factory that mints ONE `MetadataManager` per scope id and + * records every scope it was asked for. Only `ENV` carries the + * declaration; every other scope is empty — so an audit that resolves the + * wrong scope, or none, cannot pass by accident. + */ + function scopedMetadata() { + const asked: string[] = []; + const minted = new Map(); + const register = (kernel: ObjectKernel) => { + kernel.registerServiceFactory( + 'metadata', + (_ctx: unknown, scopeId?: string) => { + const key = scopeId ?? ''; + asked.push(key); + if (!minted.has(key)) { + minted.set(key, planeOver(key === ENV ? [row(ACTION, declaration())] : [])); + } + return minted.get(key)!; + }, + ServiceLifecycle.SCOPED, + ); + }; + return { asked, minted, register }; + } + + /** + * THE CARD'S ACCEPTANCE BAR: "a fixture that composes a scoped metadata + * service and asserts the audit REPORTS its declarations". + * + * The scoped plane holds one `script` declaration with no `body` and no + * handler anywhere — a button wired to nothing, which ADR-0110 D5's + * `unboundDeclarations` finding exists to name. So the audit must print + * that name. Before the fix the plane was unreachable, the declaration set + * was empty, and the audit printed NOTHING — which is why "no error was + * thrown" is not the assertion here. + */ + it('REPORTS a declaration that exists only in the scoped plane, by name', async () => { + const scoped = scopedMetadata(); + const { kernel, rec, plugin, ctx } = await compose({ + handler: false, environmentId: ENV, registerMetadata: scoped.register, + }); + live = kernel; + + await runAudit(plugin, ctx); + + expect(reportedUnbound(rec)).toBe(true); + // …and it reached that answer through the scope this kernel declares. + expect(scoped.asked).toEqual([ENV]); + }); + + /** + * The C4 symptom, from the other side: a handler the router dispatches + * (its declaration is in the scoped plane) was reported as "registered + * handler with NO declaration … REFUSED at dispatch" — an accusation + * against a healthy deployment. The scoped read clears it. + */ + it('does NOT accuse a handler whose only declaration lives in the scoped plane', async () => { + const scoped = scopedMetadata(); + const { kernel, rec, plugin, ctx } = await compose({ + handler: true, environmentId: ENV, registerMetadata: scoped.register, + }); + live = kernel; + + await runAudit(plugin, ctx); + + expect(accused(rec)).toBe(false); + // The declaration IS bound now, so neither finding fires at all. + expect(reportedUnbound(rec)).toBe(false); + }); + + /** + * IDENTITY — the deciding property, and the reason the awaited resolution + * was taken over an injected reader. `ctx.getServiceScoped(name, scopeId)` + * and the dispatcher's `defaultKernel.getServiceAsync(name, scopeId)` both + * land on `PluginLoader.getService`, which caches per scope. So the object + * the audit read IS the object the router resolves through — not an equal + * one, and not a second instance minted beside it. + */ + it('reads the SAME instance the router resolves through, and mints no second one', async () => { + const scoped = scopedMetadata(); + const { kernel, plugin, ctx } = await compose({ + handler: true, environmentId: ENV, registerMetadata: scoped.register, + }); + live = kernel; + + await runAudit(plugin, ctx); + + // The audit is what caused the mint — before the fix this array is empty. + expect(scoped.asked).toEqual([ENV]); + const auditPlane = scoped.minted.get(ENV); + expect(auditPlane).toBeInstanceOf(MetadataManager); + + // The router's own resolution, unchanged: the dispatcher's scoped branch. + const routerPlane = await kernel.getServiceAsync('metadata', ENV); + expect(routerPlane).toBe(auditPlane); + expect(scoped.asked).toEqual([ENV]); // still one mint — nothing was duplicated + // And that shared instance really does serve the declaration. + expect((await routerPlane.loadManyKeyed('action')).map((e) => e.name)).toEqual([ACTION]); + }); + + /** + * REGRESSION GUARD for every shipped composition. `metadata` is a STATIC + * instance today (`packages/metadata/src/plugin.ts`), and a kernel can + * declare an `environmentId` beside it — `os dev` boots as `'env_local'`. + * The scoped attempt must not shadow that plane: `registerService` writes + * `PluginLoader.serviceInstances` as well as the kernel map, so the scoped + * lookup answers with the very same object rather than missing it. + */ + it('still reads a STATICALLY registered plane on a kernel that declares an environmentId', async () => { + const plane = planeOver([row(ACTION, declaration())]); + const { kernel, rec, plugin, ctx } = await compose({ + handler: false, + environmentId: ENV, + registerMetadata: (k) => k.registerService('metadata', plane), + }); + live = kernel; + + await runAudit(plugin, ctx); + + expect(reportedUnbound(rec)).toBe(true); + expect(await kernel.getServiceAsync('metadata', ENV)).toBe(plane); + }); + + /** + * THE BOUNDARY THAT REMAINS, pinned so it is not re-filed as this defect. + * A kernel serving several environments declares no single `environmentId`, + * so a boot-time audit has no scope to name and the synchronous lookup + * stands — it reports the handler as undeclared, and that is the honest + * answer for an audit that cannot know which scope it is auditing. + * Auditing per environment is a different inventory with a different + * lifecycle. Warn-only and exception-proof throughout. + */ + it('BOUNDARY — with no declared environmentId there is no scope to ask for, and the audit says so', async () => { + const scoped = scopedMetadata(); + const { kernel, rec, plugin, ctx } = await compose({ + handler: true, registerMetadata: scoped.register, + }); + live = kernel; + + await expect(runAudit(plugin, ctx)).resolves.toBeUndefined(); + + expect(scoped.asked).toEqual([]); // nothing was minted — no scope was named + expect(accused(rec)).toBe(true); // …and the audit reports what it could not clear + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index e3b3bdda7f..8d0178ae00 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -2489,6 +2489,76 @@ export class ObjectQLPlugin implements Plugin { } } + /** + * [#15252] The metadata plane {@link runGovernanceInventory} audits against, + * resolved the way a REQUEST resolves it. + * + * The audit used to take it from `ctx.getService('metadata')` alone. That + * accessor reads two synchronous maps — the kernel's own `services` and + * `PluginLoader.getServiceInstance` (which reads `serviceInstances`) — and + * NEITHER of them holds a scoped instance: `PluginLoader` mints those into + * `scopedServices`, keyed by scope id. So against a composition that + * registers `metadata` with `ServiceLifecycle.SCOPED` the call threw + * `Service 'metadata' is async - use await` BEFORE any read method ran, the + * wiring below swallowed the throw into "no metadata plane at all", and the + * audit reported that scope's declarations as absent — silently, because an + * empty declaration set is indistinguishable from a plane holding nothing. + * + * The router never had that problem: `HttpDispatcher.resolveService` asks + * `defaultKernel.getServiceAsync(name, scopeId)` FIRST, with the request's + * environment id, and only then falls back to the synchronous accessors. So + * this mirrors that order, with the one scope id a boot-time caller can + * legitimately name — `environmentId`, which declares that this kernel + * serves ONE environment. `ctx.getServiceScoped` and the dispatcher's + * `getServiceAsync(name, scopeId)` both land on `PluginLoader.getService`, + * so for a scoped registration this resolves the SAME cached instance the + * router resolves through. That identity is the point: an audit reading a + * different instance than the router dispatches from cannot say anything + * true about what the router will refuse. + * + * Unchanged for every shipped composition, and checked rather than assumed: + * `ObjectKernel.registerService` writes the kernel map AND + * `PluginLoader.serviceInstances`, so a statically registered plane + * (`packages/metadata/src/plugin.ts`) comes back from the scoped attempt as + * the very same object the synchronous lookup returns. The fallback covers + * every other shape — a host with no scoped accessor at all + * (`KernelBase`/`LiteKernel` throws "not supported"), a test double whose + * context implements `getService` only, and a kernel declaring no + * `environmentId`. + * + * ⚠️ The boundary that REMAINS, stated so it is not re-discovered as a bug: + * a kernel serving several environments at once has no single scope for a + * boot-time audit to name, so there is nothing to ask for and the + * synchronous lookup stands. Auditing per environment is a different + * inventory with a different lifecycle, not a scope id this method can + * invent. + */ + private async resolveGovernanceMetadataService( + ctx: PluginContext, + ): Promise<(IMetadataService & KeyedPluralMetadataRead) | undefined> { + const scopeId = this.environmentId; + if (scopeId && typeof ctx.getServiceScoped === 'function') { + try { + const scoped = await ctx.getServiceScoped( + 'metadata', + scopeId, + ); + if (scoped != null) return scoped; + } catch { + // Not resolvable under a scope on this host — nothing registered under + // the name, or a host with no scoped accessor. Fall through to the + // synchronous lookup, which is what every non-scoped shape answers + // from; a plane that is genuinely absent ends up `undefined` either + // way and the audit degrades exactly as it did before. + } + } + try { + return ctx.getService('metadata'); + } catch { + return undefined; + } + } + /** * [ADR-0110 D5] Audit the engine's action-handler registry against the * declarations it can dispatch for, and warn about the orphans on both @@ -2514,7 +2584,9 @@ export class ObjectQLPlugin implements Plugin { let loadStandaloneActionsKeyed: (() => Promise>) | undefined; let lookupMetadataAction: ((actionName: string) => unknown) | undefined; try { - const meta = ctx.getService('metadata'); + // [#15252] Resolved the way a request resolves it — scoped first, with + // this kernel's declared environment id. See the method's docblock. + const meta = await this.resolveGovernanceMetadataService(ctx); const loadMany = meta?.loadMany; if (meta && typeof loadMany === 'function') { loadStandaloneActions = () => loadMany.call(meta, 'action'); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 6a0b08e46a..3e47401d44 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1871,6 +1871,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/objectql/src/plugin-governance-scoped-metadata.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/objectql/src/protocol-boot-hydration-scoped.test.ts", "verb": "findOne",