From a8fc6064b8dff2b2b6136db05b0e8f26df045569 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 21:42:50 +0000 Subject: [PATCH 1/5] =?UTF-8?q?test(runtime):=20measurement=20fixture=20fo?= =?UTF-8?q?r=20#14423=20(a)=20=E2=80=94=20audit=20`loadMany`=20vs=20router?= =?UTF-8?q?=20by-name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measurement only; no behaviour change. Pins the CURRENT divergence between `runActionGovernanceInventory`'s metadata-plane source (`meta.loadMany('action')`) and `resolveRouteActionDeclaration`'s third rung (`meta.loadDiagnosed('action', name)`). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- ...action-governance-scope-divergence.test.ts | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 packages/runtime/src/action-governance-scope-divergence.test.ts diff --git a/packages/runtime/src/action-governance-scope-divergence.test.ts b/packages/runtime/src/action-governance-scope-divergence.test.ts new file mode 100644 index 0000000000..b581fa06ee --- /dev/null +++ b/packages/runtime/src/action-governance-scope-divergence.test.ts @@ -0,0 +1,315 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #14423 (a) — MEASUREMENT, not a fix. Can `meta.loadMany('action')` omit a + * name that `meta.load` / `loadDiagnosed` serves? + * + * The two sites the card names: + * - the AUDIT, `runActionGovernanceInventory` (`packages/objectql/src/ + * action-governance.ts`), whose metadata-plane source is + * `loadStandaloneActions = () => meta.loadMany('action')` — wired in + * `packages/objectql/src/plugin.ts` off `ctx.getService('metadata')`; + * - the ROUTER, `resolveRouteActionDeclaration` (`./action-execution.ts`), + * whose third rung is `meta.loadDiagnosed('action', name)` — resolved per + * request off `deps.resolveService(requestContext, 'metadata', envId)`. + * + * PR #14421 closed the registry rung. This file measures the remaining one. + * ⛔ It pins a CURRENT DIVERGENCE; it does not assert a fixed behaviour, and + * nothing here is a behaviour change. Should (a) ever be fixed, these + * expectations are the ones that must be REWRITTEN, not defended. + * + * --------------------------------------------------------------------------- + * What is real and what is doubled + * --------------------------------------------------------------------------- + * Real: `runActionGovernanceInventory` and `collectEngineActionDeclarations` + * (from `@objectstack/objectql`), `resolveRouteActionDeclaration` (this + * package), `MetadataManager` (`@objectstack/metadata`), `DatabaseLoader` + * (same), `ObjectKernel` + `PluginLoader` scoping (`@objectstack/core`). + * + * Doubled: the row store under `DatabaseLoader` (a `sys_metadata`-shaped + * engine, the same double shape `meta-overlay-read-your-writes.test.ts` + * uses), and `deps.resolveService`. The latter is a transcription of + * `HttpDispatcher.resolveService`'s first branch, quoted here so a reviewer + * can check it against `packages/runtime/src/http-dispatcher.ts` (the method + * is private, so a test cannot call it): + * + * if (scopeId && typeof this.defaultKernel.getServiceAsync === 'function') { + * const svc = await this.defaultKernel.getServiceAsync(name, scopeId); + * if (svc != null) return svc; + * } + * + * The audit's lookup is NOT transcribed: `ObjectKernel.getService(name)` + * delegates straight to the `context.getService` that `plugin.ts` calls, so + * C4 exercises the real accessor on both sides. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectKernel, ServiceLifecycle } from '@objectstack/core'; +import { MetadataManager, DatabaseLoader } from '@objectstack/metadata'; +import type { MetadataLoader } from '@objectstack/metadata'; +import { runActionGovernanceInventory, collectEngineActionDeclarations } from '@objectstack/objectql'; +import { resolveRouteActionDeclaration, type ActionExecutionDeps } from './action-execution.js'; + +/** The name under test, and the handler key it is registered on. */ +const ACTION = 'promote_lead'; +const OBJECT_KEY = 'global'; // object-less action — `GLOBAL_ACTION_OBJECT_KEY` + +/** A `sys_metadata`-shaped read engine over a fixed row array. */ +function readEngine(rows: Array>, opts: { failFind?: () => Error } = {}) { + const matches = (r: Record, w: Record) => + Object.entries(w).every(([k, v]) => r[k] === v); + return { + async find(_table: string, q: any) { + if (opts.failFind) throw opts.failFind(); + return rows.filter((r) => matches(r, q?.where ?? {})); + }, + async findOne(_table: string, q: any) { + return rows.find((r) => matches(r, q?.where ?? {})) ?? null; + }, + async count(_table: string, q: any) { + return rows.filter((r) => matches(r, q?.where ?? {})).length; + }, + async insert(_table: string, data: any) { rows.push(data); return data; }, + async update(_table: string, data: any) { return data; }, + async delete() { return { deleted: 1 }; }, + }; +} + +/** 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>, opts: { failFind?: () => Error } = {}) { + const mgr = new MetadataManager({}); + mgr.registerLoader(new DatabaseLoader({ + engine: readEngine(rows, opts) as any, + trackHistory: false, + cache: { enabled: false }, + } as any) as unknown as MetadataLoader); + return mgr; +} + +/** Silent logger + the warnings the audit emitted, for reading back. */ +function recorder() { + const warnings: Array<{ message: string; meta?: Record }> = []; + return { + warnings, + logger: { + warn: (message: string, meta?: Record) => { warnings.push({ message, meta }); }, + debug: () => {}, + }, + }; +} + +/** Did the audit accuse `ACTION` of being a handler with no declaration? */ +function auditAccused(warnings: Array<{ message: string; meta?: Record }>): boolean { + return warnings.some((w) => + w.message.includes('registered handlers with NO declaration') && + ((w.meta?.handlers as string[]) ?? []).includes(`${OBJECT_KEY}:${ACTION}`)); +} + +/** + * Run the audit exactly as `ObjectQLPlugin.runGovernanceInventory` does, with + * ONE handler registered and NO object-embedded declaration and NO registry + * item — so the metadata plane is the only source that can clear it. + */ +async function runAudit(meta: any) { + const { warnings, logger } = recorder(); + const loadMany = meta?.loadMany; + const loadStandaloneActions = meta && typeof loadMany === 'function' + ? () => loadMany.call(meta, 'action') + : undefined; + await runActionGovernanceInventory({ + registered: [{ objectName: OBJECT_KEY, actionName: ACTION }], + objects: [], + loadStandaloneActions, + lookupRegistryAction: () => undefined, // rung 2 holds nothing — #14421's rung is not the one under test + logger, + }); + return { accused: auditAccused(warnings), warnings }; +} + +/** Run the router's declaration resolution against `meta`, by name. */ +async function runRouter(meta: any, envId?: string) { + const deps = { + // Transcription of `HttpDispatcher.resolveService`'s scoped branch — see header. + resolveService: async (_ctx: any, name: string, scopeId?: string) => + (name === 'metadata' ? (typeof meta === 'function' ? await meta(scopeId) : meta) : undefined), + getObjectQL: async () => null, + } as unknown as ActionExecutionDeps; + return resolveRouteActionDeclaration(deps, {} as any, { + ql: { registry: { getItem: () => undefined } }, // rung 1+2 hold nothing + objectName: OBJECT_KEY, + actionName: ACTION, + envId, + }); +} + +describe('#14423 (a) — can `loadMany` omit a name `load` serves?', () => { + /** + * C1 — CONTROL. One plane, one scope, a row whose `name` COLUMN and whose + * body `name` agree. If the harness cannot show AGREEMENT here, nothing it + * says about disagreement is worth reading. + */ + it('C1 control — same plane, name column == body.name: both reads answer, audit and router AGREE', async () => { + const meta = planeOver([row(ACTION, { name: ACTION, type: 'script', target: ACTION })]); + + const enumerated = await meta.loadMany('action'); + const byName = await meta.loadDiagnosed('action', ACTION); + + expect(enumerated.map((a: any) => a?.name)).toEqual([ACTION]); + expect(byName.data?.name).toBe(ACTION); + + const audit = await runAudit(meta); + const router = await runRouter(meta); + + expect(router.action?.name).toBe(ACTION); // router resolves it + expect(audit.accused).toBe(false); // and the audit agrees it is declared + }); + + /** + * C2 — the row-key / body-name shape (#14205's `MetadataKeyedItem` + * defect, measured there for `view`). `DatabaseLoader.load` filters on the + * `name` COLUMN and returns `rowToData(row)` — the BODY, with the column + * deliberately not folded in. `loadMany` returns bodies only. So the name + * is served by `load` and is NOT ENUMERABLE from `loadMany`'s answer. + */ + it('C2 — row keyed by the `name` COLUMN, body carries none: `load` serves it, the audit cannot name it', async () => { + const meta = planeOver([row(ACTION, { type: 'script', target: ACTION })]); // body has NO `name` + + const enumerated = await meta.loadMany('action'); + const byName = await meta.loadDiagnosed('action', ACTION); + + expect(byName.data).toBeTruthy(); // load SERVES the name + expect(enumerated).toHaveLength(1); // loadMany returns the body + expect(enumerated.map((a: any) => a?.name)).toEqual([undefined]); // ...unnamed + + // The audit keys declarations by `action.name` and drops a nameless one. + const declarations = await collectEngineActionDeclarations([], () => meta.loadMany('action')); + expect(declarations).toHaveLength(0); + + const audit = await runAudit(meta); + const router = await runRouter(meta); + + expect(router.action).toBeTruthy(); // router: the declaration EXISTS + expect(audit.accused).toBe(true); // audit: "registered handler with NO declaration" + }); + + /** + * C3 — the plural read fails while the by-name read answers. This is the + * card's literal question on ONE service instance: `MetadataManager. + * loadMany` catches a loader's plural failure, logs it via + * `reportLoaderReadFailure` and CONTINUES ("every list served from now on + * is a PARTIAL set presented as a complete one" — its own words), while + * `loadDiagnosed` walks `loader.load` and is served. + */ + it('C3 — plural read throws, by-name read answers: `loadMany` omits the name `load` serves', async () => { + const rows = [row(ACTION, { name: ACTION, type: 'script', target: ACTION })]; + const meta = planeOver(rows, { + failFind: () => Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432'), { code: 'ECONNREFUSED' }), + }); + + const enumerated = await meta.loadMany('action'); + const byName = await meta.loadDiagnosed('action', ACTION); + + expect(enumerated).toEqual([]); // enumeration: the name is ABSENT + expect(byName.data?.name).toBe(ACTION); // by name: SERVED + expect(byName.degraded).toBe(false); // and not even reported degraded + + const audit = await runAudit(meta); + const router = await runRouter(meta); + + expect(router.action?.name).toBe(ACTION); + expect(audit.accused).toBe(true); + }); + + /** + * C4 — the shape the card names: an env-scoped kernel where enumeration + * and by-name reads answer from DIFFERENT SCOPES. + * + * `metadata` is registered `SCOPED`, so `PluginLoader.getService` mints one + * instance per `scopeId`. The audit's accessor + * (`ObjectKernel.getService` → `context.getService`) reads only the static + * `services` map and `PluginLoader.getServiceInstance`, and the latter + * reads `serviceInstances` — never `scopedServices`. So the audit's lookup + * cannot see a scoped instance at all, and `plugin.ts` swallows the throw + * into `loadStandaloneActions === undefined`. The router's lookup, given + * the request's `envId`, gets the env's own plane. + */ + it('C4 — env-scoped `metadata`: the audit\'s lookup and the router\'s answer from different scopes', async () => { + const kernel = new ObjectKernel({}); + const perEnv = new Map(); + kernel.registerServiceFactory( + 'metadata', + (_ctx: any, scopeId?: string) => { + const key = scopeId ?? ''; + if (!perEnv.has(key)) { + // Only `env_a` has the declaration; every other scope is empty. + perEnv.set(key, planeOver(key === 'env_a' + ? [row(ACTION, { name: ACTION, type: 'script', target: ACTION })] + : [])); + } + return perEnv.get(key)!; + }, + ServiceLifecycle.SCOPED, + ); + + // The audit's lookup — `ctx.getService('metadata')` in plugin.ts, inside its try/catch. + let auditMeta: any; + let auditLookupError: string | undefined; + try { auditMeta = kernel.getService('metadata'); } + catch (e: any) { auditLookupError = e?.message ?? String(e); } + + // The router's lookup — the dispatcher's scoped branch, with the request's envId. + const routerMeta = await kernel.getServiceAsync('metadata', 'env_a'); + + expect(auditLookupError).toBeTruthy(); // the audit gets NO metadata plane at all + expect(auditMeta).toBeUndefined(); + expect(routerMeta).toBeInstanceOf(MetadataManager); + expect((await routerMeta.loadDiagnosed('action', ACTION)).data?.name).toBe(ACTION); + + const audit = await runAudit(auditMeta); + const router = await runRouter(async (scopeId?: string) => + scopeId ? kernel.getServiceAsync('metadata', scopeId) : undefined, 'env_a'); + + expect(router.action?.name).toBe(ACTION); // the router dispatches it + expect(audit.accused).toBe(true); // the audit calls it undeclared + }); + + /** + * C5 — the honest negative. With `metadata` a SINGLETON and one scope, + * every configuration above collapses: both reads hit the same instance, + * the same loader set and the same `baseFilter`, so nothing diverges. + * `DatabaseLoader.baseFilter` applies `organization_id` identically to + * `load` and `loadMany`, and `environmentId` is accepted-but-ignored + * (ADR-0008 §0) — there is no env dimension INSIDE a plane to diverge on. + */ + it('C5 — singleton `metadata`, one scope: both lookups return the SAME instance, no divergence', async () => { + const kernel = new ObjectKernel({}); + const plane = planeOver([row(ACTION, { name: ACTION, type: 'script', target: ACTION })]); + kernel.registerServiceFactory('metadata', () => plane, ServiceLifecycle.SINGLETON); + + const routerMeta = await kernel.getServiceAsync('metadata', 'env_a'); + const auditMeta = kernel.getService('metadata'); + + expect(routerMeta).toBe(plane); + expect(auditMeta).toBe(plane); // the scopeId is ignored — one instance + + const audit = await runAudit(auditMeta); + const router = await runRouter(auditMeta, 'env_a'); + + expect(router.action?.name).toBe(ACTION); + expect(audit.accused).toBe(false); // both sources agree + }); +}); From 321a4876edfd64cc40da793c5e1cb84656a7938a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 22:19:10 +0000 Subject: [PATCH 2/5] wip: recovered from container restart Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- ...action-governance-scope-divergence.test.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/action-governance-scope-divergence.test.ts b/packages/runtime/src/action-governance-scope-divergence.test.ts index b581fa06ee..ba673ba953 100644 --- a/packages/runtime/src/action-governance-scope-divergence.test.ts +++ b/packages/runtime/src/action-governance-scope-divergence.test.ts @@ -54,10 +54,25 @@ import { resolveRouteActionDeclaration, type ActionExecutionDeps } from './actio const ACTION = 'promote_lead'; const OBJECT_KEY = 'global'; // object-less action — `GLOBAL_ACTION_OBJECT_KEY` -/** A `sys_metadata`-shaped read engine over a fixed row array. */ +/** + * 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 (`ensureSchema` returns immediately on the + * `engine` branch), 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 — the conforming shape `check:where-matcher` + * exists to keep: `{ $or: [...] }` must not silently match nothing. + */ function readEngine(rows: Array>, opts: { failFind?: () => Error } = {}) { const matches = (r: Record, w: Record) => - Object.entries(w).every(([k, v]) => r[k] === v); + 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) { if (opts.failFind) throw opts.failFind(); @@ -69,9 +84,6 @@ function readEngine(rows: Array>, opts: { failFind?: () async count(_table: string, q: any) { return rows.filter((r) => matches(r, q?.where ?? {})).length; }, - async insert(_table: string, data: any) { rows.push(data); return data; }, - async update(_table: string, data: any) { return data; }, - async delete() { return { deleted: 1 }; }, }; } From 911d11599c35297950a461120d8553b2ac1bcc30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 22:34:43 +0000 Subject: [PATCH 3/5] =?UTF-8?q?test(runtime):=20add=20C6=20=E2=80=94=20the?= =?UTF-8?q?=20shipped=20filesystem=20plane=20reproduces=20(a)=20with=20no?= =?UTF-8?q?=20injection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- ...action-governance-scope-divergence.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/runtime/src/action-governance-scope-divergence.test.ts b/packages/runtime/src/action-governance-scope-divergence.test.ts index ba673ba953..2f3a23ac4c 100644 --- a/packages/runtime/src/action-governance-scope-divergence.test.ts +++ b/packages/runtime/src/action-governance-scope-divergence.test.ts @@ -43,9 +43,13 @@ * C4 exercises the real accessor on both sides. */ +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, it, expect } from 'vitest'; import { ObjectKernel, ServiceLifecycle } from '@objectstack/core'; import { MetadataManager, DatabaseLoader } from '@objectstack/metadata'; +import { NodeMetadataManager } from '@objectstack/metadata/node'; import type { MetadataLoader } from '@objectstack/metadata'; import { runActionGovernanceInventory, collectEngineActionDeclarations } from '@objectstack/objectql'; import { resolveRouteActionDeclaration, type ActionExecutionDeps } from './action-execution.js'; @@ -211,6 +215,12 @@ describe('#14423 (a) — can `loadMany` omit a name `load` serves?', () => { const declarations = await collectEngineActionDeclarations([], () => meta.loadMany('action')); expect(declarations).toHaveLength(0); + // The plane KNOWS the name — the keyed enumeration serves it. It is + // `loadMany`, the UNKEYED plural read the audit was wired to, that + // cannot carry it. (⛔ Naming the remedy is not shipping it: rewiring + // `loadStandaloneActions` is a behaviour change and out of scope here.) + expect(await meta.listNames('action')).toContain(ACTION); + const audit = await runAudit(meta); const router = await runRouter(meta); @@ -324,4 +334,52 @@ describe('#14423 (a) — can `loadMany` omit a name `load` serves?', () => { expect(router.action?.name).toBe(ACTION); expect(audit.accused).toBe(false); // both sources agree }); + + /** + * C6 — the SHIPPED single-plane composition, no failure injection and no + * scoping: a real `NodeMetadataManager` over a real `FilesystemLoader`, + * which is what the in-process `os dev` boot runs. + * + * `FilesystemLoader.load` resolves a name by COMPOSING a path + * (`findFile` -> `ROOT/action/.json`), so identity is path-derived; + * `loadMany` globs the directory and returns BODIES. A flat file whose body + * carries no `name` is therefore served by `load` and comes back unnamed + * from `loadMany` — and `collectEngineActionDeclarations` requires + * `typeof action.name === 'string'`, so the audit never sees it. + * + * This is C2's mechanism in the OTHER shipped loader, which is what makes it + * a rule rather than a `DatabaseLoader` quirk: #14205's finding (identity is + * the key the store holds an item under, not `body.name`) reaches both. + */ + it('C6 — real filesystem plane, body carries no `name`: `load` serves it, `loadMany` returns it unnamed, the audit cannot name it', async () => { + const root = await mkdtemp(join(tmpdir(), 'os-14423-')); + try { + await mkdir(join(root, 'action'), { recursive: true }); + // Identity lives in the FILE NAME; the body deliberately has none. + await writeFile(join(root, 'action', `${ACTION}.json`), + JSON.stringify({ type: 'script', target: ACTION }), 'utf8'); + + const meta = new NodeMetadataManager({ rootDir: root } as any); + + const enumerated = await meta.loadMany('action'); + const byName = await meta.loadDiagnosed('action', ACTION); + + expect(byName.data).toBeTruthy(); // load SERVES the name + expect(byName.degraded).toBe(false); // and reports nothing wrong + expect(enumerated).toHaveLength(1); + expect(enumerated.map((a: any) => a?.name)).toEqual([undefined]); // ...unnamed + expect(await meta.listNames('action')).toContain(ACTION); // the KEYED read has it + + const declarations = await collectEngineActionDeclarations([], () => meta.loadMany('action')); + expect(declarations).toHaveLength(0); + + const audit = await runAudit(meta); + const router = await runRouter(meta); + + expect(router.action).toBeTruthy(); // router: the declaration EXISTS + expect(audit.accused).toBe(true); // audit: "registered handler with NO declaration" + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); From 99ff95c174569e76b2ca1d73b2ce9c88f32c7d6b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 22:40:26 +0000 Subject: [PATCH 4/5] test(runtime): make the fixture's engine double conform to the double gates Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- ...action-governance-scope-divergence.test.ts | 47 +++++++++++++++---- scripts/engine-double-contract.pinned.json | 5 ++ 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/runtime/src/action-governance-scope-divergence.test.ts b/packages/runtime/src/action-governance-scope-divergence.test.ts index 2f3a23ac4c..71cec99c5c 100644 --- a/packages/runtime/src/action-governance-scope-divergence.test.ts +++ b/packages/runtime/src/action-governance-scope-divergence.test.ts @@ -49,6 +49,7 @@ import { join } from 'node:path'; import { describe, it, expect } from 'vitest'; import { ObjectKernel, ServiceLifecycle } from '@objectstack/core'; import { MetadataManager, DatabaseLoader } from '@objectstack/metadata'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { NodeMetadataManager } from '@objectstack/metadata/node'; import type { MetadataLoader } from '@objectstack/metadata'; import { runActionGovernanceInventory, collectEngineActionDeclarations } from '@objectstack/objectql'; @@ -70,7 +71,7 @@ const OBJECT_KEY = 'global'; // object-less action — `GLOBAL_ACTION_OBJECT_KEY * combinator as a field name — the conforming shape `check:where-matcher` * exists to keep: `{ $or: [...] }` must not silently match nothing. */ -function readEngine(rows: Array>, opts: { failFind?: () => Error } = {}) { +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}'`); @@ -78,11 +79,20 @@ function readEngine(rows: Array>, opts: { failFind?: () return r[k] === v; }); return { - async find(_table: string, q: any) { - if (opts.failFind) throw opts.failFind(); - return rows.filter((r) => matches(r, q?.where ?? {})); + async find(table: string, q: any) { + const hits = rows.filter((r) => matches(r, q?.where ?? {})); + // The caller's bound, applied AFTER the filter and by PRESENCE — + // `check:objectql-double-limit`'s conforming shape. A double that + // ignores `limit` answers more rows than the real engine would, + // which is how a paging defect stays green in a suite. + void table; + return typeof q?.limit === 'number' ? hits.slice(0, q.limit) : hits; }, - async findOne(_table: string, q: any) { + 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) { @@ -91,6 +101,25 @@ function readEngine(rows: Array>, opts: { failFind?: () }; } +/** + * The same engine with its LIST read down — a separate double rather than a + * flag on {@link readEngine}, deliberately. An injected `failFind` hook makes + * the base double undrivable by `check:objectql-double-limit`'s control probe + * (the probe substitutes a stub for the hook, the double calls it, and the + * candidate files as UNJUDGED debt instead of being graded). Overriding `find` + * on the base leaves `findOne` — the verb `check:engine-double-contract` pins + * — reading the base's implementation, which is what that gate's third-spelling + * note requires of an override. + */ +function listDownEngine(rows: Array>) { + return { + ...readEngine(rows), + async find() { + throw Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432'), { code: 'ECONNREFUSED' }); + }, + }; +} + /** A `sys_metadata` row: identity in the `name` COLUMN, body in `metadata`. */ function row(name: string, body: Record) { return { @@ -106,10 +135,10 @@ function row(name: string, body: Record) { } /** A real `MetadataManager` over a real `DatabaseLoader` over `rows`. */ -function planeOver(rows: Array>, opts: { failFind?: () => Error } = {}) { +function planeOver(rows: Array>, engine: unknown = readEngine(rows)) { const mgr = new MetadataManager({}); mgr.registerLoader(new DatabaseLoader({ - engine: readEngine(rows, opts) as any, + engine: engine as any, trackHistory: false, cache: { enabled: false }, } as any) as unknown as MetadataLoader); @@ -238,9 +267,7 @@ describe('#14423 (a) — can `loadMany` omit a name `load` serves?', () => { */ it('C3 — plural read throws, by-name read answers: `loadMany` omits the name `load` serves', async () => { const rows = [row(ACTION, { name: ACTION, type: 'script', target: ACTION })]; - const meta = planeOver(rows, { - failFind: () => Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432'), { code: 'ECONNREFUSED' }), - }); + const meta = planeOver(rows, listDownEngine(rows)); const enumerated = await meta.loadMany('action'); const byName = await meta.loadDiagnosed('action', ACTION); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 89a5672181..f8ba1fdb75 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3001,6 +3001,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/runtime/src/action-governance-scope-divergence.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/runtime/src/domains/meta-published-runtime-publish.test.ts", "verb": "delete", From 51fced926cb33803b2778eee40dfa62df4c90cc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 22:52:13 +0000 Subject: [PATCH 5/5] test(runtime): type the audit's service lookup with its contract, not `any` Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- .../src/action-governance-scope-divergence.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/action-governance-scope-divergence.test.ts b/packages/runtime/src/action-governance-scope-divergence.test.ts index 71cec99c5c..d72ff2d522 100644 --- a/packages/runtime/src/action-governance-scope-divergence.test.ts +++ b/packages/runtime/src/action-governance-scope-divergence.test.ts @@ -50,6 +50,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectKernel, ServiceLifecycle } from '@objectstack/core'; import { MetadataManager, DatabaseLoader } from '@objectstack/metadata'; import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import type { IMetadataService } from '@objectstack/spec/contracts'; import { NodeMetadataManager } from '@objectstack/metadata/node'; import type { MetadataLoader } from '@objectstack/metadata'; import { runActionGovernanceInventory, collectEngineActionDeclarations } from '@objectstack/objectql'; @@ -314,11 +315,14 @@ describe('#14423 (a) — can `loadMany` omit a name `load` serves?', () => { ServiceLifecycle.SCOPED, ); - // The audit's lookup — `ctx.getService('metadata')` in plugin.ts, inside its try/catch. - let auditMeta: any; + // The audit's lookup — `ctx.getService('metadata')` in plugin.ts, inside its + // try/catch. Typed with the slot's CONTRACT, not `any`: the audit reads + // `meta.loadMany` off whatever this returns, and erasing the result is + // exactly the shape `check:slot-lookup` refuses (#4251). + let auditMeta: IMetadataService | undefined; let auditLookupError: string | undefined; - try { auditMeta = kernel.getService('metadata'); } - catch (e: any) { auditLookupError = e?.message ?? String(e); } + try { auditMeta = kernel.getService('metadata'); } + catch (e: unknown) { auditLookupError = e instanceof Error ? e.message : String(e); } // The router's lookup — the dispatcher's scoped branch, with the request's envId. const routerMeta = await kernel.getServiceAsync('metadata', 'env_a');