From 89cfd2a2582baadd2bc990b6342ced03009f1a08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:14:28 +0000 Subject: [PATCH 01/15] chore(objectql): temporary census instrument on the unresolved-name fall-through Records every object name the engine's resolver hands to the driver without a registry entry, with its caller frames, into the file named by OS_TEST_UNRESOLVED_CENSUS. Reverted before the refusal lands. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- packages/objectql/src/engine.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index b2c9bcc0813..ce2868b2cfd 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1,6 +1,21 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { AsyncLocalStorage } from 'node:async_hooks'; +// CENSUS-INSTRUMENT-21516 (temporary; reverted before the fix lands) +import { appendFileSync as census21516Append } from 'node:fs'; +function census21516(name: string): void { + const out = typeof process !== 'undefined' ? process.env.OS_TEST_UNRESOLVED_CENSUS : undefined; + if (!out) return; + const frames = String(new Error().stack ?? '') + .split('\n') + .slice(2) + .map((l) => l.trim()) + .filter((l) => l.startsWith('at ') && !l.includes('node:internal') && !l.includes('/node_modules/')) + .slice(0, 12); + try { + census21516Append(out, JSON.stringify({ name, pid: process.pid, cwd: process.cwd(), frames }) + '\n'); + } catch { /* instrument only */ } +} import { QueryAST, QueryInput, HookContext, ServiceObject } from '@objectstack/spec/data'; // [commit 74155c735] The defaulting node schema `fillQueryAstDefaults` runs author input // through — the declared `.default()` stays in `packages/spec`, the engine @@ -9210,6 +9225,7 @@ export class ObjectQL implements IObjectQLEngine { return StorageNameMapping.resolveTableName(schema); } // Return name as-is (canonical name = table name; no FQN prefix to strip) + census21516(name); return StorageNameMapping.resolveTableName({ name }); } From 915a3e139c2ee94ae152c232afd36d264a72f8aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:48:35 +0000 Subject: [PATCH 02/15] chore(objectql): remove the temporary census instrument The census it measured is recorded in the pull request. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- packages/objectql/src/engine.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index ce2868b2cfd..b2c9bcc0813 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1,21 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { AsyncLocalStorage } from 'node:async_hooks'; -// CENSUS-INSTRUMENT-21516 (temporary; reverted before the fix lands) -import { appendFileSync as census21516Append } from 'node:fs'; -function census21516(name: string): void { - const out = typeof process !== 'undefined' ? process.env.OS_TEST_UNRESOLVED_CENSUS : undefined; - if (!out) return; - const frames = String(new Error().stack ?? '') - .split('\n') - .slice(2) - .map((l) => l.trim()) - .filter((l) => l.startsWith('at ') && !l.includes('node:internal') && !l.includes('/node_modules/')) - .slice(0, 12); - try { - census21516Append(out, JSON.stringify({ name, pid: process.pid, cwd: process.cwd(), frames }) + '\n'); - } catch { /* instrument only */ } -} import { QueryAST, QueryInput, HookContext, ServiceObject } from '@objectstack/spec/data'; // [commit 74155c735] The defaulting node schema `fillQueryAstDefaults` runs author input // through — the declared `.default()` stays in `packages/spec`, the engine @@ -9225,7 +9210,6 @@ export class ObjectQL implements IObjectQLEngine { return StorageNameMapping.resolveTableName(schema); } // Return name as-is (canonical name = table name; no FQN prefix to strip) - census21516(name); return StorageNameMapping.resolveTableName({ name }); } From 5a49d6a29a4ed56b785e07215fa13c275acfe508 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:52:05 +0000 Subject: [PATCH 03/15] fix(objectql): an in-process verb refuses an object name the registry does not resolve resolveObjectName no longer hands an unresolved name to the driver as a raw table name. It throws the data door's own OBJECT_NOT_FOUND 404, built by one factory in @objectstack/core that the door's object-existence gate now calls too. judgeFilter keeps judging the filter for such a name (it reads nothing). Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- packages/core/src/index.ts | 5 +++ packages/core/src/utils/object-not-found.ts | 40 +++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 39 +++++++++--------- packages/objectql/src/engine.ts | 32 ++++++++++++--- .../spec/src/contracts/objectql-engine.ts | 4 +- 5 files changed, 95 insertions(+), 25 deletions(-) create mode 100644 packages/core/src/utils/object-not-found.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 024a5624982..2278c6fb03f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -155,6 +155,11 @@ export * from './utils/metadata-activation-store.js'; // with. `@objectstack/metadata-protocol` re-exports it from its original home. export * from './utils/record-not-found.js'; +// The one `OBJECT_NOT_FOUND` envelope: the data door's object-existence gate +// and the engine's in-process verbs refuse a name the registry does not +// resolve with it (one name space for both doors). +export * from './utils/object-not-found.js'; + // Export in-memory fallbacks for core-criticality services export * from './fallbacks/index.js'; diff --git a/packages/core/src/utils/object-not-found.ts b/packages/core/src/utils/object-not-found.ts new file mode 100644 index 00000000000..fb1e6952d98 --- /dev/null +++ b/packages/core/src/utils/object-not-found.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The 404 an object name answers when the schema registry does not resolve it. + * + * One name space, two doors. The generic data door (the protocol's + * object-existence gate) and the engine's in-process verbs (`find`, `findOne`, + * `count`, `aggregate`, `insert`, `update`, `delete`, `validate`) resolve an + * object name through the same registry, and a name the registry does not hold + * is refused by both with this one envelope: `code: 'OBJECT_NOT_FOUND'`, + * `status: 404`, and the name the caller asked for on `object`. + * + * Why the engine refuses too: an in-process verb used to hand an unresolved + * name to the driver as a raw table name. Every guard keyed by a registered + * object name then judged a name that named no object, while the driver read + * whatever table the spelling reached. The door already refused that name, so + * an in-process caller (a sandboxed body, an action handler, a hook) could + * read what the door would not serve. + * + * Why it lives in `@objectstack/core`: ADR-0076 D2's boundary ratchet + * (`core-boundary.ratchet.test.ts`) keeps `engine.ts` from importing + * `@objectstack/metadata-protocol`, where the door's envelope was first + * written. `recordNotFoundError` moved down here for the same reason, and both + * doors call this factory rather than each spelling the envelope. + * + * The wire answer is the data-error classifier's (`mapDataError`, + * `@objectstack/types`), which reads `code` and `object`; this message is the + * operator-facing text and stays the door's original sentence. + */ +export function objectNotFoundError(object: string): Error { + const err = new Error(`Object '${object}' not found`) as Error & { + code?: string; + status?: number; + object?: string; + }; + err.code = 'OBJECT_NOT_FOUND'; + err.status = 404; + err.object = object; + return err; +} diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ad70a9e7426..d7af25ce728 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3,7 +3,7 @@ import type { DataProtocol, MetadataProtocol, PackageProtocol, } from '@objectstack/spec/api'; -import { IDataEngine, engineCanRollBack, recordNotFoundError } from '@objectstack/core'; +import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError } from '@objectstack/core'; import { declaredUserMessage, readEnvWithDeprecation, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types'; // [#6285] ADR-0105 D1's authority on "does this deployment wall organizations?". // `resolveMultiOrgEnabled()` is DEMOTED and its own doc comment says answering @@ -10237,21 +10237,24 @@ export class ObjectStackProtocolImplementation implements * * The REST API-exposure gate (`enforceApiAccess`, ADR-0049 / #1889) skips * objects it cannot find in metadata, and justified that with "the data - * path will 404 anyway". It would not. `engine.find` resolves an - * UNREGISTERED name straight to a physical table name - * (`resolveObjectName` → `StorageNameMapping.resolveTableName({ name })`), - * so the request only 404'd as a *side effect* of the driver complaining - * about a missing table (which the REST layer recognises by matching the - * driver's error string) — and did not 404 at all when a table with that - * name happened to exist: out-of-band DDL, a registration that failed - * after `syncObjectSchema` had already run, a registration race. In that - * window the exposure gate was silently skipped and the rows were served. + * path will 404 anyway". It would not. `engine.find` then resolved an + * UNREGISTERED name straight to a physical table name, so the request + * only 404'd as a *side effect* of the driver complaining about a missing + * table (which the REST layer recognises by matching the driver's error + * string) — and did not 404 at all when a table with that name happened + * to exist: out-of-band DDL, a registration that failed after + * `syncObjectSchema` had already run, a registration race. In that window + * the exposure gate was silently skipped and the rows were served. * * The gate lives HERE, at the protocol ingress, for the same reason - * `enforceApiAccess` does: this is the external API boundary. Internal - * callers (hooks, flows, migrations, raw ObjectQL) talk to the engine - * directly and are deliberately unaffected — `apiEnabled` and this check - * both control automatic API exposure, not data access. + * `enforceApiAccess` does: this is the external API boundary, and it + * answers before the query is parsed. `apiEnabled` controls automatic API + * exposure, not data access, and internal callers (hooks, flows, + * migrations, raw ObjectQL) are unaffected by it. The object-existence + * half is no longer the door's alone: the engine's in-process verbs now + * refuse a name the registry does not resolve with this same envelope + * (`objectNotFoundError`, `@objectstack/core`), so an in-process caller + * cannot read a table by a name this gate refuses. * * ## Tiering — mirrors the #3545 decision recorded in `api-exposure.ts` * @@ -10340,11 +10343,9 @@ export class ObjectStackProtocolImplementation implements } return; } - const err: any = new Error(`Object '${object}' not found`); - err.code = 'OBJECT_NOT_FOUND'; - err.status = 404; - err.object = object; - throw err; + // The one envelope, shared with the engine's in-process verbs, which + // refuse an unresolved name the same way (`objectNotFoundError`). + throw objectNotFoundError(object); } /** diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index b2c9bcc0813..80775bde818 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -116,6 +116,9 @@ import { // boundary ratchet forbids the `/core` entry closure — engine.ts included — // from importing `@objectstack/metadata-protocol`, where it was written. recordNotFoundError, + // The data door's object-existence 404, shared for the same reason: an + // in-process verb refuses a name the registry does not resolve with it. + objectNotFoundError, } from '@objectstack/core'; import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js'; import { bridgeAuthzInvalidation } from './authz-invalidation-bridge.js'; @@ -9203,14 +9206,25 @@ export class ObjectQL implements IObjectQLEngine { * Accepts the canonical short name (e.g., 'account') or, for explicit * cross-package disambiguation, the canonical object name (e.g., 'account'). The result is * the physical table name derived via `StorageNameMapping.resolveTableName`. + * + * One name space with the data door: the target resolves ONLY through the + * registry. A name the registry does not resolve is refused with the door's + * own `OBJECT_NOT_FOUND` 404 (`objectNotFoundError`), never handed to the + * driver as a raw table name. Before this, every in-process guard keyed by a + * registered object name could be stepped around by naming the target some + * other way, and an in-process caller (a sandboxed body, an action handler, + * a hook) read what the door refused to serve. + * + * Platform code that must address storage without a registry entry has a + * declared path a body cannot reach: the driver itself + * (`datasource(name)`, `getDriverForObject(name)`), held by host code only. */ private resolveObjectName(name: string): string { const schema = this._registry.getObject(name); if (schema) { return StorageNameMapping.resolveTableName(schema); } - // Return name as-is (canonical name = table name; no FQN prefix to strip) - return StorageNameMapping.resolveTableName({ name }); + throw objectNotFoundError(name); } /** @@ -9219,17 +9233,25 @@ export class ObjectQL implements IObjectQLEngine { * docblock states the semantics; {@link judgeWhereAdmission} records the * pipeline and why it is the same one every verb runs. * - * The object name resolves exactly as the verbs resolve it, and the field map - * is the one they read, so the verdict (and the object name inside its + * A registered name resolves exactly as the verbs resolve it, and the field + * map is the one they read, so the verdict (and the object name inside its * message) is the one execution would give. It stops before `getDriver`: * nothing is resolved from or sent to a datasource. + * + * It judges the FILTER, not the object's existence. For a name the registry + * does not resolve, the verbs refuse the object itself (`OBJECT_NOT_FOUND`, + * before admission); this member still judges the filter there, with no + * field map, as the contract states, because it reads nothing and hands the + * name to no driver. That keeps the authoring-time judge (`os validate`, + * whose engine holds only the stack's own objects) answering about the + * filter for an object the platform or another package defines. */ judgeFilter( objectName: string, where: EngineQueryOptions['where'], options?: EngineFilterJudgementOptions, ): EngineFilterJudgement { - const object = this.resolveObjectName(objectName); + const object = this._registry.getObject(objectName) ? this.resolveObjectName(objectName) : objectName; return judgeWhereAdmission( object, options?.operation ?? 'find', diff --git a/packages/spec/src/contracts/objectql-engine.ts b/packages/spec/src/contracts/objectql-engine.ts index 74c8cdbd7d3..6b7db32fcf8 100644 --- a/packages/spec/src/contracts/objectql-engine.ts +++ b/packages/spec/src/contracts/objectql-engine.ts @@ -286,7 +286,9 @@ export interface IObjectQLEngine extends IDataEngine { * middleware composes after admission (RLS, sharing, tenant scope). * The judge sees the object's declared field map from the registry. For * an object the registry does not know, the field-map doors answer - * nothing and the schema-free doors still judge, as at execution. + * nothing and the schema-free doors still judge. Execution refuses such + * an object before admission (`OBJECT_NOT_FOUND`, 404): that answer is + * about the object, not the filter, and is not this member's verdict. * - **The verdict is not redacted.** `message` is the refusing door's own * text. A caller judging a filter whose content it must not disclose * (a read-scope policy, the #5367 ruling) withholds the message itself. From 5e876116afd66e26d7503b7e5fc31f322f156710 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:52:45 +0000 Subject: [PATCH 04/15] fix(objectql,metadata-protocol): internal org/history probes treat the engine's refusal as the not-provisioned case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine now refuses an unresolved name, so three best-effort platform probes that read a system table by a constant name no longer reach the driver when that object is not registered in a lean/bare composition: ObjectQL.probeInstallOrganizations (sys_organization), SeedLoaderService.resolveSoleOrganizationId (sys_organization) and SysMetadataRepository's history counters (sys_metadata_history). Each now recognises OBJECT_NOT_FOUND attributed to its own object as the same benign "not provisioned here" case it already recognises for a missing table — a path a body cannot reach, never the resolver's old raw-table fall-through. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- packages/metadata-protocol/src/seed-loader.ts | 14 +++++++- .../src/sys-metadata-repository.ts | 11 ++++++ packages/objectql/src/engine.ts | 36 +++++++++++-------- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index 41b999ca3ec..1af7535c841 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -1649,12 +1649,24 @@ export class SeedLoaderService implements ISeedLoaderService { // makes below — never a hand-rolled message test, so one vocabulary of // "benign driver error" serves every seam that needs one. // + // [#21516] The same absence in a composition that registers no + // `sys_organization` object at all (a single-tenant/lean runtime): the + // engine's in-process verbs now refuse an unresolved name with + // `OBJECT_NOT_FOUND` before any driver is asked, rather than reaching a + // table by that raw name. It is the not-provisioned case the missing-table + // arm already covers — no organization object here, so "no sole + // organization" is the truth and the historical NULL is right. Attributed + // on the error's own `object`, like the predicate above: a refusal naming + // a different object is not evidence about `sys_organization`. + // // Everything else (connection loss, a timeout, a permission denial, a // driver fault) means organizations may well exist and simply were not // seen. It propagates, envelope intact: the seed run fails loudly instead // of writing a batch of rows nobody will be able to see. No new error code // and no new result field — the caller receives the read's own failure. - if (!isMissingTableError(error, 'sys_organization')) throw error; + const refused = error as { code?: unknown; object?: unknown } | null | undefined; + const refusedThisObject = refused?.code === 'OBJECT_NOT_FOUND' && refused.object === 'sys_organization'; + if (!isMissingTableError(error, 'sys_organization') && !refusedThisObject) throw error; } return undefined; } diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index a644551edfc..9de1a376cdd 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -2087,6 +2087,17 @@ export class SysMetadataRepository implements MetadataRepository { // [commit 4cda78c9b] Both callers read `this.historyTable`, so a failure naming any // other relation is not evidence that THIS one is empty. if (isMissingTableError(error, this.historyTable)) return 1; + // [#21516] The same emptiness in a composition that does not register the + // history object at all (a lean embedding, a bare-kernel test): the + // engine's in-process verbs now refuse an unresolved name with + // `OBJECT_NOT_FOUND` before any driver is asked, rather than reaching a + // table by that raw name. It is the not-provisioned case the missing-table + // arm above already covers — there is no history object here, so no + // lineage to collide with and 1 really is the next number. Attributed on + // the error's own `object`, like the relation check above: a refusal + // naming a different object is not evidence about `this.historyTable`. + const refused = error as { code?: unknown; object?: unknown } | null | undefined; + if (refused?.code === 'OBJECT_NOT_FOUND' && refused.object === this.historyTable) return 1; if (!this.historyCounterFailureReported) { this.historyCounterFailureReported = true; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 80775bde818..f5793bff919 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -5563,21 +5563,19 @@ export class ObjectQL implements IObjectQLEngine { * `resolveFileReferences` and `cascadeDeleteRelations` make — never a * hand-rolled code test. * - * ⚠️ The old comment's "`sys_organization` may not be registered at all (a - * lean embedding, a bare-kernel test)" is NOT a second benign cause, and a - * predicate written for it would have guarded a case that cannot reach this - * catch. Measured on this seam: - * - * - an object missing from the REGISTRY does not fail the read at all on a - * driver that tolerates an unknown table — {@link find} returns `[]` - * through the normal path, never entering the catch; - * - a strict driver surfaces that same install as a MISSING TABLE, i.e. as - * the one benign cause above; - * - and "no driver at all" cannot reach here: {@link getDriver} answers every - * object from the default driver, which the first {@link registerDriver} - * always sets and nothing ever clears, so the only engine whose routing - * fails for `sys_organization` is one with no drivers — where the write - * that would have asked already failed on its OWN object. + * A SECOND benign cause, now that an unresolved name is refused rather than + * handed to the driver: `sys_organization` is not registered at all (a lean + * embedding, a bare-kernel test). Before an in-process verb refused an + * unresolved name, that install reached the driver and read `[]` on a + * tolerant one or a MISSING TABLE on a strict one — the one driver cause + * above. It now resolves to the engine's own `OBJECT_NOT_FOUND` before any + * driver is asked, so this probe asks the REGISTRY first and treats an + * absent `sys_organization` as the lean-install case it always was: there is + * no organization object here, so there is no organization to derive. This + * is the probe handling absence itself, on a path a body cannot reach — it + * never relies on the resolver's old raw-table fall-through, which is now + * gone. It is not a spelling allow-list: the question is "is the object + * provisioned," asked of the one registry, not "is this one of N names." * * Everything else — connection loss, pool exhaustion, a timeout mid-boot, a * datasource that never connected ({@link DatasourceUnavailableError}), a @@ -5591,6 +5589,14 @@ export class ObjectQL implements IObjectQLEngine { */ private async probeInstallOrganizations(): Promise { if (this.organizationProbeMemo) return this.organizationProbeMemo; + // The lean-install case: no `sys_organization` object is registered, so + // there is no organization to derive. Asking the registry first keeps the + // probe off the resolver's refusal for an unregistered name (a path a body + // cannot reach), exactly as the old raw-table fall-through read `[]`. + if (!this._registry.getObject(ORGANIZATION_OBJECT)) { + this.organizationProbeMemo = []; + return this.organizationProbeMemo; + } let ids: readonly string[] = []; try { const rows = await this.find(ORGANIZATION_OBJECT, { From 7bf46051df13fee4f4980223e187812a3e665cba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:26:09 +0000 Subject: [PATCH 05/15] test(objectql): metadata-write harnesses register the stored-metadata family they write through Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .../objectql/src/delete-meta-response-conformance.test.ts | 4 ++++ .../objectql/src/protocol-derived-provenance-doors.test.ts | 5 +++++ .../objectql/src/protocol-picklist-served-roundtrip.test.ts | 4 ++++ .../objectql/src/protocol-publish-canonical-fold.test.ts | 4 ++++ .../src/protocol-save-meta-repo-path-real-engine.test.ts | 5 +++++ .../objectql/src/publish-meta-response-conformance.test.ts | 4 ++++ .../src/publish-package-drafts-response-conformance.test.ts | 4 ++++ packages/objectql/src/save-meta-response-conformance.test.ts | 4 ++++ 8 files changed, 34 insertions(+) diff --git a/packages/objectql/src/delete-meta-response-conformance.test.ts b/packages/objectql/src/delete-meta-response-conformance.test.ts index 652630d1b00..c584ea2d230 100644 --- a/packages/objectql/src/delete-meta-response-conformance.test.ts +++ b/packages/objectql/src/delete-meta-response-conformance.test.ts @@ -47,6 +47,9 @@ import type { ServiceObject } from '@objectstack/spec/data'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { DeleteMetaItemResponseSchema } from '@objectstack/spec/api'; import { ObjectQL } from './engine.js'; +// [#21516] The rest of the stored-metadata family the repository writes through: the +// engine refuses a name the registry does not resolve, so the harness registers it as a boot does. +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; const sysMetadataObject: ServiceObject = { name: 'sys_metadata', @@ -156,6 +159,7 @@ async function makeProtocol() { engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(sysMetadataObject, 'test-package'); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any, 'test-package'); return { p: new ObjectStackProtocolImplementation(engine), stores }; } diff --git a/packages/objectql/src/protocol-derived-provenance-doors.test.ts b/packages/objectql/src/protocol-derived-provenance-doors.test.ts index d7306fe0c2f..05736c873a9 100644 --- a/packages/objectql/src/protocol-derived-provenance-doors.test.ts +++ b/packages/objectql/src/protocol-derived-provenance-doors.test.ts @@ -47,6 +47,9 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco // that would otherwise read as the subject refusing the write. import { hashSpec } from '@objectstack/metadata-core'; import { ObjectQL } from './engine.js'; +// [#21516] The rest of the stored-metadata family the repository writes through: the +// engine refuses a name the registry does not resolve, so the harness registers it as a boot does. +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; const PKG = 'app.sdbh'; const ENV = 'env_test'; @@ -166,6 +169,7 @@ async function boot(driver: unknown) { engine.registerDriver(driver as any, true); await engine.init(); engine.registry.registerObject(sysMetadataObject as any); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any); const protocol = new ObjectStackProtocolImplementation(engine as any, undefined, ENV); return { engine, protocol }; } @@ -308,6 +312,7 @@ describe('#16702 door 2 — hydration restates the fact for EVERY type, not only engine.registerDriver(driver as any, true); await engine.init(); engine.registry.registerObject(sysMetadataObject as any); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any); // environmentId omitted — the unscoped (control-plane) kernel is the // only one whose list read hydrates the process-wide registry. const protocol = new ObjectStackProtocolImplementation(engine as any); diff --git a/packages/objectql/src/protocol-picklist-served-roundtrip.test.ts b/packages/objectql/src/protocol-picklist-served-roundtrip.test.ts index 33a341f2931..c60e9b35dd2 100644 --- a/packages/objectql/src/protocol-picklist-served-roundtrip.test.ts +++ b/packages/objectql/src/protocol-picklist-served-roundtrip.test.ts @@ -16,6 +16,9 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { ObjectQL } from './engine.js'; +// [#21516] The rest of the stored-metadata family the repository writes through: the +// engine refuses a name the registry does not resolve, so the harness registers it as a boot does. +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; const sysMetadataObject = { name: 'sys_metadata', @@ -155,6 +158,7 @@ describe('picklist — the protocol object read and the authoring door', () => { engine.registerDriver(makeStubDriver().driver, true); await engine.init(); engine.registry.registerObject(sysMetadataObject); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any); engine.registerApp({ id: 'com.test.lists', name: 'lists', picklists: [{ name: 'industry', label: 'Industry', options: [{ label: 'Tech', value: 'tech' }] }], diff --git a/packages/objectql/src/protocol-publish-canonical-fold.test.ts b/packages/objectql/src/protocol-publish-canonical-fold.test.ts index 3205200a49c..3b28e2808e5 100644 --- a/packages/objectql/src/protocol-publish-canonical-fold.test.ts +++ b/packages/objectql/src/protocol-publish-canonical-fold.test.ts @@ -127,6 +127,9 @@ import { describe, it, expect } from 'vitest'; import type { ServiceObject } from '@objectstack/spec/data'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { ObjectQL } from './engine.js'; +// [#21516] The rest of the stored-metadata family the repository writes through: the +// engine refuses a name the registry does not resolve, so the harness registers it as a boot does. +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; const sysMetadataObject: ServiceObject = { name: 'sys_metadata', @@ -238,6 +241,7 @@ async function makeProtocol() { engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(sysMetadataObject, 'test-package'); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any, 'test-package'); const protocol = new ObjectStackProtocolImplementation(engine, undefined, 'env_prod'); const rows = () => Array.from(stores.get('sys_metadata')?.values() ?? []) as any[]; // [#8819] The history table, for the rollback verb in group D — it restores diff --git a/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts b/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts index 8f2dfec2b79..b59eaa6379c 100644 --- a/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts +++ b/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts @@ -13,6 +13,9 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { ObjectQL } from './engine.js'; +// [#21516] The rest of the stored-metadata family the repository writes through: the +// engine refuses a name the registry does not resolve, so the harness registers it as a boot does. +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; const sysMetadataObject = { name: 'sys_metadata', @@ -148,6 +151,7 @@ describe('saveMetaItem — repository write path against real ObjectQL (PR-10d.4 engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(sysMetadataObject); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any); protocol = new ObjectStackProtocolImplementation(engine); }); @@ -248,6 +252,7 @@ describe('deleteMetaItem — repository write path against real ObjectQL (PR-10d engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(sysMetadataObject); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any); engine.registry.registerObject(sysMetadataHistoryObject); protocol = new ObjectStackProtocolImplementation(engine); }); diff --git a/packages/objectql/src/publish-meta-response-conformance.test.ts b/packages/objectql/src/publish-meta-response-conformance.test.ts index a1396f5a2ff..fb875d22d38 100644 --- a/packages/objectql/src/publish-meta-response-conformance.test.ts +++ b/packages/objectql/src/publish-meta-response-conformance.test.ts @@ -34,6 +34,9 @@ import type { ServiceObject } from '@objectstack/spec/data'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { PublishMetaItemResponseSchema } from '@objectstack/spec/api'; import { ObjectQL } from './engine.js'; +// [#21516] The rest of the stored-metadata family the repository writes through: the +// engine refuses a name the registry does not resolve, so the harness registers it as a boot does. +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; const sysMetadataObject: ServiceObject = { name: 'sys_metadata', @@ -154,6 +157,7 @@ async function makeProtocol() { engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(sysMetadataObject, 'test-package'); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any, 'test-package'); return new ObjectStackProtocolImplementation(engine); } diff --git a/packages/objectql/src/publish-package-drafts-response-conformance.test.ts b/packages/objectql/src/publish-package-drafts-response-conformance.test.ts index 63f687725a1..cb00daaeb0f 100644 --- a/packages/objectql/src/publish-package-drafts-response-conformance.test.ts +++ b/packages/objectql/src/publish-package-drafts-response-conformance.test.ts @@ -33,6 +33,9 @@ import type { ServiceObject } from '@objectstack/spec/data'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { PublishPackageDraftsResponseSchema } from '@objectstack/spec/api'; import { ObjectQL } from './engine.js'; +// [#21516] The rest of the stored-metadata family the repository writes through: the +// engine refuses a name the registry does not resolve, so the harness registers it as a boot does. +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; const sysMetadataObject: ServiceObject = { name: 'sys_metadata', @@ -134,6 +137,7 @@ async function makeProtocol() { engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(sysMetadataObject, 'test-package'); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any, 'test-package'); // The base every draft here is bound to is INSTALLED: a flow may be saved // only into a package the registry holds (#20863). Its manifest declares // no namespace, so the ADR-0028 prefix pre-flight is unchanged. diff --git a/packages/objectql/src/save-meta-response-conformance.test.ts b/packages/objectql/src/save-meta-response-conformance.test.ts index a17c48cc645..da5df510557 100644 --- a/packages/objectql/src/save-meta-response-conformance.test.ts +++ b/packages/objectql/src/save-meta-response-conformance.test.ts @@ -44,6 +44,9 @@ import type { ServiceObject } from '@objectstack/spec/data'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SaveMetaItemResponseSchema } from '@objectstack/spec/api'; import { ObjectQL } from './engine.js'; +// [#21516] The rest of the stored-metadata family the repository writes through: the +// engine refuses a name the registry does not resolve, so the harness registers it as a boot does. +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; const sysMetadataObject: ServiceObject = { name: 'sys_metadata', @@ -159,6 +162,7 @@ async function makeProtocol() { engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(sysMetadataObject, 'test-package'); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any, 'test-package'); return new ObjectStackProtocolImplementation(engine); } From 672a348777a40e78c3f240f9e6abe43e46931132 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:32:02 +0000 Subject: [PATCH 06/15] test(objectql): migrate the pins that encoded the raw-table fall-through Deliberate probes of an unregistered name now assert the refusal (the data door's OBJECT_NOT_FOUND envelope, nothing reaching the driver); harnesses where the fall-through was incidental register the objects they write through. The #3770 case-B pin keeps the door's 404 and flips its engine assertion. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- ...2-no-field-map-type-blind-lowering.test.ts | 62 +++++++++---------- .../src/engine-aggregate-filter.test.ts | 41 +++++------- ...e-aggregate-having-comparand-shape.test.ts | 38 +++++------- ...regate-reference-verdict-positions.test.ts | 17 ++--- .../objectql/src/engine-judge-filter.test.ts | 17 +++-- .../engine-organization-probe-outage.test.ts | 17 +++-- .../engine-summary-recompute-context.test.ts | 4 ++ .../engine-temporal-comparand-door.test.ts | 5 +- .../engine-undeclared-field-preflight.test.ts | 11 ++-- .../engine-undeclared-update-field.test.ts | 11 ++-- packages/objectql/src/engine.test.ts | 10 ++- ...bal-search-federated-object-recall.test.ts | 6 ++ .../src/global-search-palette-recall.test.ts | 6 ++ .../objectql/src/plugin.integration.test.ts | 25 ++++++++ .../src/protocol-unregistered-object.test.ts | 12 ++-- .../src/query-expression-conformance.test.ts | 10 ++- ...egistry-field-type-refused-at-door.test.ts | 4 ++ 17 files changed, 178 insertions(+), 118 deletions(-) diff --git a/packages/objectql/src/engine-20822-no-field-map-type-blind-lowering.test.ts b/packages/objectql/src/engine-20822-no-field-map-type-blind-lowering.test.ts index 4df4ada196c..333c5e2a5c8 100644 --- a/packages/objectql/src/engine-20822-no-field-map-type-blind-lowering.test.ts +++ b/packages/objectql/src/engine-20822-no-field-map-type-blind-lowering.test.ts @@ -18,6 +18,17 @@ * below; `engine-shared-filter-lowering-seam.test.ts` pins the rest of it). * * The witness is the recording driver's `where`, as in the seam's own pin. + * + * [#21516] The verbs no longer reach this branch through an unregistered name. + * An in-process verb resolves its target only through the registry and + * refuses a name it does not resolve with the data door's `OBJECT_NOT_FOUND`, + * before admission and before any driver; a registered object always carries + * a field map (the registry injects the system columns). So the verb cases + * below now pin the refusal, the typed control is unchanged, and the + * no-field-map stage is still exercised where it is reachable: the judge, which + * reads nothing and judges the filter for a name the registry does not hold. + * Item 7's rule — a seam that cannot read the declared type lowers type-blind — + * is unchanged. */ import { beforeEach, describe, expect, it } from 'vitest'; @@ -73,39 +84,24 @@ describe('[#20822] an object with no field map: the engine seam lowers type-blin expect(engine.registry.getObject(REGISTERED)).toBeDefined(); }); - it('find: a bare-day `$lte` on any column reaches the driver as `$lt` the next day', async () => { - await engine.find(UNREGISTERED, { where: { closed_at: { $lte: '2026-07-28' } } }); - expect(lastWhere('find')).toEqual({ closed_at: { $lt: '2026-07-29' } }); - // Type-blind means every column: no declaration says `note` is not a datetime. - await engine.find(UNREGISTERED, { where: { note: { $lte: '2026-07-28' } } }); - expect(lastWhere('find')).toEqual({ note: { $lt: '2026-07-29' } }); - }); - - it('find: `$between` with a bare-day maximum splits and widens its upper end', async () => { - await engine.find(UNREGISTERED, { where: { closed_at: { $between: ['2026-07-01', '2026-07-28'] } } }); - expect(lastWhere('find')).toEqual({ closed_at: { $gte: '2026-07-01', $lt: '2026-07-29' } }); - }); - - it('findOne and count take the same reading', async () => { - await engine.findOne(UNREGISTERED, { where: { closed_at: { $lte: '2026-07-28' } } }); - expect(lastWhere('findOne') ?? lastWhere('find')).toEqual({ closed_at: { $lt: '2026-07-29' } }); - await engine.count(UNREGISTERED, { where: { closed_at: { $lte: '2026-07-28' } } }); - expect(lastWhere('count')).toEqual({ closed_at: { $lt: '2026-07-29' } }); - }); - - it('aggregate: its `where` takes the same reading', async () => { - await engine.aggregate(UNREGISTERED, { - where: { closed_at: { $lte: '2026-07-28' } }, - groupBy: ['note'], - aggregations: [{ function: 'count', alias: 'n' }], - } as EngineAggregateOptions); - expect(lastWhere('find')).toEqual({ closed_at: { $lt: '2026-07-29' } }); - }); - - it('an instant, and `$gte` / `$lt` on a bare day, are never widened', async () => { - const where = { closed_at: { $lte: '2026-07-28T12:00:00.000Z' }, note: { $gte: '2026-07-01', $lt: '2026-07-28' } }; - await engine.find(UNREGISTERED, { where }); - expect(lastWhere('find')).toBe(where); + it('[#21516] every verb refuses the unregistered name with the door\'s OBJECT_NOT_FOUND, and the driver sees nothing', async () => { + const where = { closed_at: { $lte: '2026-07-28' } }; + const verbs: Array<[string, () => Promise]> = [ + ['find', () => engine.find(UNREGISTERED, { where })], + ['findOne', () => engine.findOne(UNREGISTERED, { where })], + ['count', () => engine.count(UNREGISTERED, { where })], + ['aggregate', () => engine.aggregate(UNREGISTERED, { + where, + groupBy: ['note'], + aggregations: [{ function: 'count', alias: 'n' }], + } as EngineAggregateOptions)], + ]; + for (const [verb, call] of verbs) { + const refused: any = await call().then(() => undefined, (e) => e); + expect({ verb, code: refused?.code, status: refused?.status, object: refused?.object }) + .toEqual({ verb, code: 'OBJECT_NOT_FOUND', status: 404, object: UNREGISTERED }); + } + expect(seen).toEqual([]); }); it('control — a field map keeps the typed scope: only the declared datetime is rewritten', async () => { diff --git a/packages/objectql/src/engine-aggregate-filter.test.ts b/packages/objectql/src/engine-aggregate-filter.test.ts index 46186edd7b4..762440c32a6 100644 --- a/packages/objectql/src/engine-aggregate-filter.test.ts +++ b/packages/objectql/src/engine-aggregate-filter.test.ts @@ -864,33 +864,22 @@ describe('[#21255] per-aggregation filter — a plain { $field } across two comp }); } - it('an object the registry does not declare is not judged — the card\'s query is answered, as written', async () => { - // The fail-open direction an `addDays` pair already takes for a - // registry-less host: no declaration, no class, no verdict. - // - // [#21242] What answers it is `@objectstack/formula`'s matcher, which no - // longer keeps a whole-day copy of the bare-day upper bound: a pair that - // reaches it without a seam is compared as written (ADR-0053 D-D1 item 5). - // None of the six ORDERS closes on its due day, so they count 3 either - // way. `o7` does, at 15:00: the deleted copy read its due day as "through - // that day" and counted it (4 of 7); as written, the instant's text sorts - // above the bare day, and it is not counted (3 of 7). - const ON_THE_DUE_DAY = { - id: 'o7', customer_id: 'c3', amount: 10, cap: 1, placed_on: '2026-01-05', due_on: '2026-01-05', grace: 0, - opened_at: '2026-01-05T08:00:00.000Z', closed_at: '2026-01-05T15:00:00.000Z', slot: '15:00:00', created_at: '2026-09-01T00:00:00.000Z', - }; + it('an object the registry does not declare is not judged — [#21516] the engine refuses the object itself', async () => { + // [#21516] An in-process verb resolves its target only through the + // registry, and refuses a name it does not resolve with the data door's + // own `OBJECT_NOT_FOUND` before any filter door or read — so no class + // verdict is invented about the pair, and nothing is answered from a table + // reached by its raw name. The no-declaration reading of the pair (no + // class, compared as written, #21242) stays pinned where a caller can + // still reach it without a registry: the direct `applyInMemoryAggregation` + // case further down this file. for (const native of [true, false]) { - for (const [rows, expected] of [ - [ORDERS, { opp_count: 6, picked: 3 }], - [[...ORDERS, ON_THE_DUE_DAY], { opp_count: 7, picked: 3 }], - ] as const) { - const { driver } = makeCountingDriver(rows, native); - const engine = new ObjectQL(); - engine.registerDriver(driver, true); - await engine.init(); - expect(await engine.aggregate('crm_order', withFilter({ closed_at: { $lte: { $field: 'due_on' } } }))) - .toEqual([expected]); - } + const { driver } = makeCountingDriver(ORDERS, native); + const engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + await expect(engine.aggregate('crm_order', withFilter({ closed_at: { $lte: { $field: 'due_on' } } }))) + .rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404 }); } }); }); diff --git a/packages/objectql/src/engine-aggregate-having-comparand-shape.test.ts b/packages/objectql/src/engine-aggregate-having-comparand-shape.test.ts index e60aed700d5..248f823a069 100644 --- a/packages/objectql/src/engine-aggregate-having-comparand-shape.test.ts +++ b/packages/objectql/src/engine-aggregate-having-comparand-shape.test.ts @@ -942,30 +942,22 @@ describe('[#20127] having — a { $field, addDays } pair is judged by each aggre }); } - it('a registry-less host is not judged — the pair is answered, as written, as an addDays pair is', async () => { - // No declaration, so no column has a type or a class: the fail-open - // direction #20127 took for an `addDays` pair, kept for a plain one. - // - // [#21242] What answers it is `@objectstack/formula`'s matcher, which no - // longer keeps a whole-day copy of the bare-day upper bound: a pair that - // reaches it without a seam is compared as written (ADR-0053 D-D1 item - // 5). No group of DT_ROWS closes on its first due day, so they keep - // `c1` either way. `c4` does, at 15:00: the deleted copy read the day as - // "through that day" and kept `c4` beside `c1`; as written, the - // instant's text sorts above the bare day, and `c4` is dropped. - const ON_THE_DUE_DAY = { - customer_id: 'c4', amount: 10, cap: 1, placed_on: '2026-01-05', due_on: '2026-01-05', grace: 0, - opened_at: '2026-01-05T08:00:00.000Z', closed_at: '2026-01-05T15:00:00.000Z', - }; + it('a registry-less host is not judged — [#21516] the engine refuses the object itself, before any read', async () => { + // [#21516] An in-process verb resolves its target only through the + // registry and refuses a name it does not resolve with the data door's + // own `OBJECT_NOT_FOUND` before `having` or any read runs: no class + // verdict is invented, and no group is answered from a table reached by + // its raw name. How the matcher compares a pair as written (#21242) is + // pinned on the registered cases beside this one. for (const [door, native] of DOORS) { - for (const rows of [DT_ROWS, [...DT_ROWS, ON_THE_DUE_DAY]]) { - const { driver } = makeDriver(rows, native); - const engine = new ObjectQL(); - engine.registerDriver(driver, true); - await engine.init(); - const out = await engine.aggregate(OBJECT, { ...DT_QUERY, having: { last_closed: { $lte: { $field: 'first_due' } } } }); - expect(groups(out), door).toEqual(['c1']); - } + const { driver } = makeDriver(DT_ROWS, native); + const engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + const refused: any = await engine + .aggregate(OBJECT, { ...DT_QUERY, having: { last_closed: { $lte: { $field: 'first_due' } } } }) + .then(() => undefined, (e) => e); + expect({ code: refused?.code, status: refused?.status }, door).toEqual({ code: 'OBJECT_NOT_FOUND', status: 404 }); } }); diff --git a/packages/objectql/src/engine-aggregate-reference-verdict-positions.test.ts b/packages/objectql/src/engine-aggregate-reference-verdict-positions.test.ts index 6af858568d8..72a8f33cf96 100644 --- a/packages/objectql/src/engine-aggregate-reference-verdict-positions.test.ts +++ b/packages/objectql/src/engine-aggregate-reference-verdict-positions.test.ts @@ -517,14 +517,17 @@ describe('[#21299] the positions are enumerated, not remembered', () => { describe('[#21299] a side with no declaration is not judged, at every engine-side position (recorded, as #21255 pins)', () => { const PAIR = { f_text: { $ne: { $field: 'f_image' } } }; - it('a registry-less host: the per-aggregation filter, having and applyInMemoryAggregation answer, as written', async () => { + it('a registry-less host: [#21516] the engine refuses the object; applyInMemoryAggregation, which reads no registry, answers as written', async () => { const { engine } = await makeEngine(null); - expect(await engine.aggregate(OBJECT, perAggregation(PAIR))).toEqual([{ n: 0, m: 0 }]); - expect(await engine.aggregate(OBJECT, { - groupBy: ['f_text', 'f_image'], - aggregations: [{ function: 'count', alias: 'n' }], - having: PAIR, - } as unknown as EngineAggregateOptions)).toEqual([]); + // [#21516] An in-process verb refuses a name the registry does not resolve + // with the data door's own `OBJECT_NOT_FOUND`, before the per-aggregation + // filter or `having` is judged — no verdict is invented about the pair. + for (const query of [ + perAggregation(PAIR), + { groupBy: ['f_text', 'f_image'], aggregations: [{ function: 'count', alias: 'n' }], having: PAIR } as unknown as EngineAggregateOptions, + ]) { + await expect(engine.aggregate(OBJECT, query)).rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404 }); + } expect(applyInMemoryAggregation([{ f_text: 'a', f_image: 'b' }], perAggregation(PAIR) as never)).toEqual([{ n: 1, m: 1 }]); }); diff --git a/packages/objectql/src/engine-judge-filter.test.ts b/packages/objectql/src/engine-judge-filter.test.ts index 59825e03856..c39b16c2265 100644 --- a/packages/objectql/src/engine-judge-filter.test.ts +++ b/packages/objectql/src/engine-judge-filter.test.ts @@ -312,16 +312,21 @@ describe('[#20157] ObjectQL.judgeFilter: judge a where without executing it', () }); describe('an object the registry does not know', () => { - it('the field-map doors answer nothing and the schema-free doors still judge, as at execution', async () => { + it('the field-map doors answer nothing and the schema-free doors still judge the filter', () => { // A virtual-field verdict needs the field map; with none, it is ok. expect(engine.judgeFilter('judge_unregistered', { is_open: true })).toEqual({ ok: true }); // The list-comparand shape gate needs no field map. - const where = (): Where => ({ status: { $in: 'open' } }); - const verdict = refused(engine.judgeFilter('judge_unregistered', where())); - const thrown = await refusalOf(engine.find('judge_unregistered', { where: where() })); + const verdict = refused(engine.judgeFilter('judge_unregistered', { status: { $in: 'open' } })); expect({ code: verdict.code, status: verdict.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); - expect({ code: thrown!.code, status: thrown!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); - expect(verdict.message).toBe(thrown!.message); + }); + + it('[#21516] execution refuses the OBJECT before admission — an answer about the object, not the filter', async () => { + // The contract: execution refuses an object the registry does not know + // (`OBJECT_NOT_FOUND`, 404) before any `where` door runs, and that + // refusal is not the judge's verdict. So the same filter the judge + // refuses as INVALID_FILTER is answered OBJECT_NOT_FOUND at execution. + const thrown = await refusalOf(engine.find('judge_unregistered', { where: { status: { $in: 'open' } } })); + expect({ code: thrown!.code, status: thrown!.status }).toEqual({ code: 'OBJECT_NOT_FOUND', status: 404 }); }); }); }); diff --git a/packages/objectql/src/engine-organization-probe-outage.test.ts b/packages/objectql/src/engine-organization-probe-outage.test.ts index 773f86f5c33..fb85cbf3f49 100644 --- a/packages/objectql/src/engine-organization-probe-outage.test.ts +++ b/packages/objectql/src/engine-organization-probe-outage.test.ts @@ -246,17 +246,22 @@ describe('#9261 the benign causes still answer the empty probe', () => { expect((failure as Error).message).toContain("No driver available for object 'dispatch_order'"); }); - it('an object missing from the REGISTRY never reaches the catch on a tolerant driver', async () => { - // Pinned so the next author does not write a predicate against a case that - // cannot occur: the read succeeds and returns `[]` through the NORMAL path. + it('an object missing from the REGISTRY is the lean-install case: unstamped, and the driver is never asked', async () => { + // [#21516] An in-process verb now refuses a name the registry does not + // resolve (`OBJECT_NOT_FOUND`) instead of reading a table by that raw name, + // so the probe asks the REGISTRY first: no organization object, no + // organization to derive. Same answer as before — the write proceeds + // unstamped — reached without a driver read, so a tolerant driver's `[]` + // and a strict driver's missing table are no longer what answers it. const { engine, observed } = await makeEngine({ registerOrganizationObject: false, - organizationFind: () => [], + organizationFind: () => { throw new Error('the probe must not read an unregistered organization object'); }, }); await systemInsert(engine, 'unregistered organization object'); + expect(lastWrite(observed, 'dispatch_order')?.method).toBe('create'); expect(lastWrite(observed, 'dispatch_order')?.options?.tenantId).toBeUndefined(); - // The control: the read really did happen (it is not the structural branch). - expect(observed.filter((c) => c.object === 'sys_organization' && c.method === 'find')).toHaveLength(1); + // The witness of the new mechanism: no read of the organization object at all. + expect(observed.filter((c) => c.object === 'sys_organization' && c.method === 'find')).toHaveLength(0); }); it('a healthy probe is unchanged — one organization is still stamped, and memoised', async () => { diff --git a/packages/objectql/src/engine-summary-recompute-context.test.ts b/packages/objectql/src/engine-summary-recompute-context.test.ts index 49f17447a1a..f1cbe44a47f 100644 --- a/packages/objectql/src/engine-summary-recompute-context.test.ts +++ b/packages/objectql/src/engine-summary-recompute-context.test.ts @@ -364,6 +364,10 @@ describe('[#7673] the elevation does not widen what the caller may do', () => { // The stand-in gate is grant-by-object, so narrow the child grant away for // this one probe by asking for an operation the member never held either. + // [#21516] The probe object is REGISTERED, so the answer is the permission + // gate's: the engine refuses a name its registry does not hold before any + // middleware runs, which would pin the wrong refusal. + engine.registry.registerObject({ name: 'other_object_without_grant', fields: { x: { type: 'number' } } } as any); const caught = await engine .insert('other_object_without_grant', { x: 1 }, { context: stranger } as any) .then(() => null, (e: any) => e); diff --git a/packages/objectql/src/engine-temporal-comparand-door.test.ts b/packages/objectql/src/engine-temporal-comparand-door.test.ts index 196f4f18425..933ffa0c448 100644 --- a/packages/objectql/src/engine-temporal-comparand-door.test.ts +++ b/packages/objectql/src/engine-temporal-comparand-door.test.ts @@ -370,9 +370,12 @@ describe('[#8690] the temporal-comparand door at the engine collection point', ( it('invents no verdict on an object whose field map it cannot see', async () => { // A registry-less host must not refuse a filter on a field it cannot type — // the same early return the neighbouring gates make. + // [#21516] The engine now refuses the OBJECT first, with the data door's + // own `OBJECT_NOT_FOUND`, so the temporal door still invents no verdict + // about `created_date`: the answer is about the object, never this door's. await expect( engine.find('unregistered_object', { where: { created_date: { $gte: 'last_30_days' } } }), - ).resolves.toBeDefined(); + ).rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404 }); }); }); diff --git a/packages/objectql/src/engine-undeclared-field-preflight.test.ts b/packages/objectql/src/engine-undeclared-field-preflight.test.ts index 294ca9028e9..94452e64e58 100644 --- a/packages/objectql/src/engine-undeclared-field-preflight.test.ts +++ b/packages/objectql/src/engine-undeclared-field-preflight.test.ts @@ -235,14 +235,17 @@ describe('#8682 half A — the declared-field door', () => { expect(writes.map((w) => w.data.account_number)).toEqual(['0001', '0002']); }); - it('a registry-less host gets no verdict — the driver stays the backstop', async () => { + it('a registry-less host gets no field verdict — [#21516] the object itself is refused, nothing is written', async () => { // Same discipline as the read path's doors: a door that cannot see the // field map must not invent an opinion about it. const { engine, writes } = await makeEngine({ registerObject: false, missingColumns: ['zzz_nonexistent_field'] }); - const refusal = await refusalOf(() => engine.insert('acct', { name: 'x', zzz_nonexistent_field: 'x' } as any)); + const refusal: any = await refusalOf(() => engine.insert('acct', { name: 'x', zzz_nonexistent_field: 'x' } as any)); - expect(writes).toHaveLength(1); - expect(String(refusal?.message)).toContain('has no column named zzz_nonexistent_field'); + // [#21516] The engine refuses the unresolved OBJECT first, with the data + // door's own envelope: still no field verdict, and nothing reaches the + // driver by that raw name. + expect({ code: refusal?.code, status: refusal?.status }).toEqual({ code: 'OBJECT_NOT_FOUND', status: 404 }); + expect(writes).toHaveLength(0); }); }); diff --git a/packages/objectql/src/engine-undeclared-update-field.test.ts b/packages/objectql/src/engine-undeclared-update-field.test.ts index d3e64757bf5..5abed271daf 100644 --- a/packages/objectql/src/engine-undeclared-update-field.test.ts +++ b/packages/objectql/src/engine-undeclared-update-field.test.ts @@ -298,13 +298,16 @@ describe('#8738 — the declared-field door on update()', () => { // place, and a reverse verification that turned one of these red would mean // the door had grown an opinion it is not allowed to have. describe('where the door deliberately has NO opinion (reused from #8737)', () => { - it('a registry-less host gets no verdict — the driver stays the backstop', async () => { + it('a registry-less host gets no field verdict — [#21516] the object itself is refused, nothing is written', async () => { const { engine, writes } = await makeEngine({ registration: 'none', missingColumns: ['zzz_nonexistent_field'] }); - const refusal = await refusalOf(() => engine.update('acct', { id: 'row-1', zzz_nonexistent_field: 'x' } as any)); + const refusal: any = await refusalOf(() => engine.update('acct', { id: 'row-1', zzz_nonexistent_field: 'x' } as any)); - expect(writes).toHaveLength(1); - expect(String(refusal?.message)).toContain('has no column named zzz_nonexistent_field'); + // The engine refuses a name the registry does not resolve with the data + // door's own envelope before any field door runs: still no opinion about + // the field, and the driver is no longer reached by that raw name. + expect({ code: refusal?.code, status: refusal?.status }).toEqual({ code: 'OBJECT_NOT_FOUND', status: 404 }); + expect(writes).toHaveLength(0); }); it('an EMPTY field map gets no verdict — an absence is not a prohibition', async () => { diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index a8858480fb1..b33eec83ce6 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -2062,8 +2062,14 @@ describe('ObjectQL Engine', () => { expect(mockDriver.find).toHaveBeenCalledTimes(1); // No expand query }); - it('should skip expand if schema is not registered', async () => { - vi.mocked(SchemaRegistry.getObject).mockReturnValue(undefined); + it('should skip expand if the referenced schema is not registered', async () => { + // [#21516] The read's own object must resolve through the registry + // (an in-process verb refuses a name it does not hold), so `task` is + // registered; the REFERENCED object is not, and expand leaves the + // raw id without a second driver read. + vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => (name === 'task' + ? { name: 'task', fields: { assignee: { type: 'lookup', reference: 'user' } } } as any + : undefined)); vi.mocked(mockDriver.find).mockResolvedValueOnce([ { id: 't1', assignee: 'u1' }, diff --git a/packages/objectql/src/global-search-federated-object-recall.test.ts b/packages/objectql/src/global-search-federated-object-recall.test.ts index ed601fa030e..34d53740dce 100644 --- a/packages/objectql/src/global-search-federated-object-recall.test.ts +++ b/packages/objectql/src/global-search-federated-object-recall.test.ts @@ -57,6 +57,9 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco import type { ServiceObject } from '@objectstack/spec/data'; import { ObjectQL } from './engine.js'; +// [#21516] The stored-metadata family the protocol's overlay read consults: the engine +// refuses a name the registry does not resolve, so the harness registers it as a boot does. +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { SEARCH_COMPANION_FIELD } from './search-companion.js'; type Row = Record; @@ -284,6 +287,9 @@ async function makeHarness(recall: boolean): Promise { await engine.init(); engine.registry.registerObject(accountBase, 'test'); engine.registry.registerObject(extCustomerBase, 'test'); + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + engine.registry.registerObject(o as any, 'test'); + } managedStore.seed(ACCOUNT, { id: 'acc_aurora', name: 'Aurora Holdings', billing_email: 'ap@aurora-holdings.example', diff --git a/packages/objectql/src/global-search-palette-recall.test.ts b/packages/objectql/src/global-search-palette-recall.test.ts index 69e66e26516..fcd1cb8c224 100644 --- a/packages/objectql/src/global-search-palette-recall.test.ts +++ b/packages/objectql/src/global-search-palette-recall.test.ts @@ -50,6 +50,9 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco import type { ServiceObject } from '@objectstack/spec/data'; import { ObjectQL } from './engine.js'; +// [#21516] The stored-metadata family the protocol's overlay read consults: the engine +// refuses a name the registry does not resolve, so the harness registers it as a boot does. +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { SEARCH_COMPANION_FIELD, provisionSearchCompanion } from './search-companion.js'; // --------------------------------------------------------------------------- @@ -248,6 +251,9 @@ async function makeHarness(opts?: { companion?: boolean }): Promise { engine.registry.registerObject( opts?.companion === false ? accountBase : accountProvisioned, 'test'); engine.registry.registerObject(ledgerBase, 'test'); + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + engine.registry.registerObject(o as any, 'test'); + } const protocol = new ObjectStackProtocolImplementation(engine as never); diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index 788e064eba4..694764425d9 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -5,6 +5,7 @@ import { ObjectKernel } from '@objectstack/core'; import { ObjectQLPlugin } from '../src/plugin'; import { ObjectSchema } from '@objectstack/spec/data'; import { readServiceSelfInfo } from '@objectstack/spec/api'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; describe('ObjectQLPlugin - Metadata Service Integration', () => { let kernel: ObjectKernel; @@ -971,6 +972,28 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { }); describe('Cold-Start Metadata Restoration', () => { + // [#21516] A project kernel (environmentId set) gets the stored-metadata + // family from MetadataPlugin, which these cases do not compose. Register it + // the way that plugin does — through the manifest service, after the engine + // plugin's init — so the engine resolves `sys_metadata` and the reads below + // reach the driver. The engine refuses a name its registry does not hold. + const metadataFamilyRegistrar = { + name: 'test.metadata-family', + type: 'standard', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: any) => { + ctx.getService('manifest').register({ + id: 'com.objectstack.metadata-objects', + name: 'Metadata Platform Objects', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysMetadataObject, SysMetadataHistoryObject, SysMetadataCommitObject, SysMetadataAuditObject], + }); + }, + }; + it('should restore metadata from sys_metadata via protocol.loadMetaFromDb on start', async () => { // Arrange — a driver whose find() returns persisted metadata records const findCalls: Array<{ object: string; query: any }> = []; @@ -1102,6 +1125,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { const plugin = new ObjectQLPlugin({ environmentId: 'env_1' }); await kernel.use(plugin); + await kernel.use(metadataFamilyRegistrar as any); await kernel.bootstrap(); const metaReads = findCalls.filter((c) => c.object === 'sys_metadata'); @@ -1128,6 +1152,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { const plugin = new ObjectQLPlugin({ environmentId: 'env_1', hydrateMetadataFromDb: true }); await kernel.use(plugin); + await kernel.use(metadataFamilyRegistrar as any); await kernel.bootstrap(); expect(findCalls.find((c) => c.object === 'sys_metadata')).toBeDefined(); diff --git a/packages/objectql/src/protocol-unregistered-object.test.ts b/packages/objectql/src/protocol-unregistered-object.test.ts index aa1e27b0a0c..d14eabf100e 100644 --- a/packages/objectql/src/protocol-unregistered-object.test.ts +++ b/packages/objectql/src/protocol-unregistered-object.test.ts @@ -11,7 +11,8 @@ * repo was thrown by `cloneData`. * ② the engine does not reject unregistered names: `resolveObjectName` falls * back to `StorageNameMapping.resolveTableName({ name })`, i.e. the object - * name IS used as the table name. + * name IS used as the table name. (Since #21516 the engine refuses such a + * name too, with this gate's own `OBJECT_NOT_FOUND`; case B pins both.) * ③ the 404 was therefore only ever a side effect of the DRIVER erroring on a * missing table, recognised by string-matching that error in the REST layer. * @@ -146,9 +147,12 @@ describe('#3770 — data-plane object-existence gate (real ObjectQL engine)', () protocol.findData({ object: UNREGISTERED }), ).rejects.toMatchObject(OBJECT_NOT_FOUND); - // And the row really is reachable through the engine — i.e. the test - // fails for the right reason (the gate), not because storage was empty. - expect(await engine.find(UNREGISTERED, {})).toHaveLength(1); + // The row really is in storage — the test fails for the right reason + // (the gate), not because storage was empty… + expect(Array.from(stores.get(UNREGISTERED)!.values())).toHaveLength(1); + // …and [#21516] the engine's in-process verb refuses the same name with + // the same envelope, instead of reading that table by its raw name. + await expect(engine.find(UNREGISTERED, {})).rejects.toMatchObject(OBJECT_NOT_FOUND); }); it('case A — an unregistered object with no physical table is 404 from the gate, not the driver', async () => { diff --git a/packages/objectql/src/query-expression-conformance.test.ts b/packages/objectql/src/query-expression-conformance.test.ts index baee7a054de..43a655e08b1 100644 --- a/packages/objectql/src/query-expression-conformance.test.ts +++ b/packages/objectql/src/query-expression-conformance.test.ts @@ -1197,7 +1197,11 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin const bare = new ObjectQL(); bare.registerDriver(makeStubDriver().driver, true); await bare.init(); - await expect(bare.find('unregistered_object', { where: { anything: true } })).resolves.toEqual([]); + // [#21516] The engine now refuses the OBJECT first — the door's own + // `OBJECT_NOT_FOUND` — so the field door still invents no verdict about + // `anything`: the answer is about the object, never `INVALID_FIELD`. + await expect(bare.find('unregistered_object', { where: { anything: true } })) + .rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404 }); }); // ───────────────────────────────────────────────────────────── @@ -1803,8 +1807,10 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin // the ingress gate makes when `resolveQueryFields` cannot answer). // For that host the driver-side #3821 ladder is the documented // backstop — which is exactly why the ruling KEEPS the ladder. + // [#21516] The object itself is now refused first, with the door's own + // `OBJECT_NOT_FOUND` — still no dotted verdict invented about `a.b`. await expect(engine.find('unregistered_thing', { fields: ['a.b'] })) - .resolves.toEqual([]); + .rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404 }); }); it('an `expand` sub-read raises the refusal, which the expand backstop downgrades to a warning', async () => { diff --git a/packages/objectql/src/registry-field-type-refused-at-door.test.ts b/packages/objectql/src/registry-field-type-refused-at-door.test.ts index 571b02184e6..49e3af66459 100644 --- a/packages/objectql/src/registry-field-type-refused-at-door.test.ts +++ b/packages/objectql/src/registry-field-type-refused-at-door.test.ts @@ -29,6 +29,9 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +// [#21516] The rest of the stored-metadata family the repository writes through: the +// engine refuses a name the registry does not resolve, so the harness registers it as a boot does. +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; import { hashSpec } from '@objectstack/metadata-core'; import type { EngineQueryOptions } from '@objectstack/spec/data'; import { ObjectQL } from './engine.js'; @@ -257,6 +260,7 @@ async function boot(driver: unknown) { engine.registerDriver(driver as any, true); await engine.init(); engine.registry.registerObject(sysMetadataObject as any); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any); const protocol = new ObjectStackProtocolImplementation(engine as any, undefined, ENV); return { engine, protocol }; } From 514d90ff54e8e5edc17b8f68b53ed94bd3734d54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:44:03 +0000 Subject: [PATCH 07/15] test(runtime): reshape the expected-refusal noise pins to the engine's refusal An unregistered organization object (and a view's unregistered probe object) is no longer read through the driver: the engine refuses the name first, so the declared refusal no longer occurs. The capture stays declared and each pin now asserts nothing was withheld, so a returning read turns it red. The channel-asymmetry pin registers its probe object (unprovisioned) so it still measures a real driver refusal. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .../src/app-plugin.job-data-reach.test.ts | 36 ++++++++----------- ...river-text-real-driver.integration.test.ts | 7 +++- ...ttp-status-real-driver.integration.test.ts | 7 +++- ...bulk-write-real-driver.integration.test.ts | 7 +++- ...lue-lookup-real-driver.integration.test.ts | 7 +++- .../src/default-datasource-plugin.test.ts | 7 +++- ...nested-fields-join-key.integration.test.ts | 7 +++- ...ad-refusal-noise.channel-asymmetry.test.ts | 9 +++++ ...list-commits-org-scope.integration.test.ts | 7 +++- ...-attribution-org-scope.integration.test.ts | 7 +++- ...evert-commit-org-scope.integration.test.ts | 7 +++- ...erve-audit-real-driver.integration.test.ts | 7 +++- ...nput-writeback-key-set.integration.test.ts | 17 +++++++-- ...sted-write-real-sqlite.integration.test.ts | 7 +++- ...perrow-dispatch-signal.integration.test.ts | 7 +++- ...ial-field-clear-signal.integration.test.ts | 7 +++- ...river-text-real-driver.integration.test.ts | 7 +++- ...lue-lookup-real-driver.integration.test.ts | 7 +++- 18 files changed, 127 insertions(+), 40 deletions(-) diff --git a/packages/runtime/src/app-plugin.job-data-reach.test.ts b/packages/runtime/src/app-plugin.job-data-reach.test.ts index c072268c4f7..b6ee6257ec4 100644 --- a/packages/runtime/src/app-plugin.job-data-reach.test.ts +++ b/packages/runtime/src/app-plugin.job-data-reach.test.ts @@ -108,8 +108,6 @@ const live: Array<{ driver?: SqlDriver; dir?: string; noise?: ExpectedReadRefusalCapture; - /** The channels this test's path MUST have provoked — see `harness()`. */ - requiredChannels?: readonly string[]; }> = []; /** @@ -119,6 +117,8 @@ const live: Array<{ * construction, but the driver and the engine each log the fault on the way out. * Withheld and ASSERTED rather than muted — see `expected-read-refusal-noise.ts`. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const ABSENT_TENANCY_TABLE = 'sys_organization'; afterEach(async () => { @@ -127,13 +127,14 @@ afterEach(async () => { try { await entry.engine?.destroy(); } catch { /* noop */ } try { await entry.driver?.disconnect(); } catch { /* noop */ } if (entry.dir) rmSync(entry.dir, { recursive: true, force: true }); - // A capture nobody asserts is a mute. The probe is memoised behind the - // FIRST data operation, so only the paths that actually touch the store - // provoke it — `silentChannels(required)` is the API's own answer to a - // table read on some of a file's paths and not others. The withholding - // is unconditional either way; only the must-have-fired set narrows. + // A capture nobody asserts is a mute. [#21516] Quiet by construction + // now, on every path: the engine refuses a name its registry does not + // hold before any driver, so the probe asks the registry and never reads + // the unregistered organization object. The capture stays declared (a + // returning read is still withheld and counted) and this asserts that + // nothing was. if (entry.noise) { - expect(entry.noise.silentChannels(entry.requiredChannels ?? [ABSENT_TENANCY_TABLE])).toEqual([]); + expect(entry.noise.tablesSeen()).toEqual([]); } } }); @@ -161,19 +162,10 @@ async function bootEngine(): Promise<{ engine: ObjectQL; driver: SqlDriver; nois return { engine, driver, noise }; } -/** - * @param opts.touchesStore whether this test's path performs a data operation. - * `true` (the default) requires the tenancy probe to have fired and been - * withheld; a context-shape test that never reads or writes passes `false`, - * which keeps the withholding and drops only the must-have-fired requirement. - */ -async function harness(opts: { touchesStore?: boolean } = {}): Promise { +async function harness(): Promise { const { engine, driver, noise } = await bootEngine(); const adapter = new CronJobAdapter(); - live.push({ - engine, adapter, driver, noise, - requiredChannels: opts.touchesStore === false ? [] : [ABSENT_TENANCY_TABLE], - }); + live.push({ engine, adapter, driver, noise }); const readyHooks: Array<() => Promise> = []; const ctx = { @@ -254,7 +246,7 @@ describe('#14094 — a declarative job handler has data reach (TS-config path)', it('the context is the pre-#14094 set PLUS exactly `ql` and `logger`', async () => { // Reads nothing and writes nothing — this one is about the shape. - const h = await harness({ touchesStore: false }); + const h = await harness(); const seen: Array> = []; const plugin = new AppPlugin({ @@ -282,7 +274,7 @@ describe('#14094 — a declarative job handler has data reach (TS-config path)', }); it('`data` from a manual trigger still reaches the handler beside the new members', async () => { - const h = await harness({ touchesStore: false }); + const h = await harness(); const seen: Array> = []; const plugin = new AppPlugin({ id: 'com.test.job-reach', @@ -391,7 +383,7 @@ export const meta = { builtAt: '2026-09-01T00:00:00.000Z' }; describe('#14094 — additivity (Zone 1.1)', () => { it('a handler written against the PRE-#14094 context runs unchanged, byte for byte', async () => { - const h = await harness({ touchesStore: false }); + const h = await harness(); const calls: Array<{ jobId: string; data?: unknown }> = []; // Verbatim the shape `IJobService`'s `JobHandler` declares — the type an diff --git a/packages/runtime/src/batch-row-driver-text-real-driver.integration.test.ts b/packages/runtime/src/batch-row-driver-text-real-driver.integration.test.ts index f4d20439637..7df2dfe18f7 100644 --- a/packages/runtime/src/batch-row-driver-text-real-driver.integration.test.ts +++ b/packages/runtime/src/batch-row-driver-text-real-driver.integration.test.ts @@ -85,6 +85,8 @@ const NOTE = { * Withheld and asserted rather than muted; `expected-read-refusal-noise.ts` * says why. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const ABSENT_TENANCY_TABLE = 'sys_organization'; /** @@ -115,7 +117,10 @@ describe('[#8502] a REAL driver fault is withheld from every batch row', () => { // a failure here can never leave the engine running. Every test in this // file rigs and writes, so the probe fires for each of them: this holds // for a single `-t` run as well as for the whole file. - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); noise = null; }); diff --git a/packages/runtime/src/batch-row-http-status-real-driver.integration.test.ts b/packages/runtime/src/batch-row-http-status-real-driver.integration.test.ts index 6dd59353d49..d1ab41f0da5 100644 --- a/packages/runtime/src/batch-row-http-status-real-driver.integration.test.ts +++ b/packages/runtime/src/batch-row-http-status-real-driver.integration.test.ts @@ -86,11 +86,16 @@ const CHILD = { * make that test red for a reason that has nothing to do with it. * `expected-read-refusal-noise.ts` says why this withholds instead of muting. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const ABSENT_TENANCY_TABLE = 'sys_organization'; /** [commit 13a6cb4ad] The capture is a PIN, not a mute — this is the assertion half. */ const expectExpectedNoiseWithheld = (noise: ExpectedReadRefusalCapture | null): void => { - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); }; describe('[#8570] a batch row carries the status its producer DECLARED — real driver', () => { diff --git a/packages/runtime/src/bulk-write-real-driver.integration.test.ts b/packages/runtime/src/bulk-write-real-driver.integration.test.ts index e721e31752b..8902d067945 100644 --- a/packages/runtime/src/bulk-write-real-driver.integration.test.ts +++ b/packages/runtime/src/bulk-write-real-driver.integration.test.ts @@ -106,6 +106,8 @@ function metadataFor(objects: any[]) { * Withheld and asserted rather than muted; `expected-read-refusal-noise.ts` * says why. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const ABSENT_TENANCY_TABLE = 'sys_organization'; describe('bulk-write hardening on a REAL SqlDriver (framework#3147–#3152, #3172, #3173)', () => { @@ -122,7 +124,10 @@ describe('bulk-write hardening on a REAL SqlDriver (framework#3147–#3152, #317 // failure here can never leave the engine running. Every test in this file // boots and writes, so the probe fires for each of them: this holds for a // single `-t` run as well as for the whole file. - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); noise = null; }); diff --git a/packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts b/packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts index 28306ca51a0..b90ad27a276 100644 --- a/packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts +++ b/packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts @@ -172,6 +172,8 @@ const OWNER_PACKAGE = 'com.objectstack.test.9362'; * Withheld and asserted rather than muted; `expected-read-refusal-noise.ts` * says why. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const ABSENT_TENANCY_TABLE = 'sys_organization'; // ── [#18617] The driver axis (ADR-0053 D-A3: "Postgres at minimum") ────────── @@ -382,7 +384,10 @@ function declareCascadeDeleteCell(cell: DialectCell): void { // a failure here can never leave the engine running. Every test in this // file rigs and writes, so the probe fires for each of them: this holds // for a single `-t` run as well as for the whole file. - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); noise = null; }); diff --git a/packages/runtime/src/default-datasource-plugin.test.ts b/packages/runtime/src/default-datasource-plugin.test.ts index 3501f06ae71..77d5553375a 100644 --- a/packages/runtime/src/default-datasource-plugin.test.ts +++ b/packages/runtime/src/default-datasource-plugin.test.ts @@ -34,6 +34,8 @@ const ENV = 'OS_ALLOW_DRIVER_CONNECT_FAILURE'; * each log the fault on the way out. Withheld and asserted in that one case * rather than muted; `expected-read-refusal-noise.ts` says why. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const ABSENT_TENANCY_TABLE = 'sys_organization'; async function assemble(opts: { @@ -197,7 +199,10 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (# // [commit 13a6cb4ad] The capture is a PIN, not a mute: the probe's two log lines // are withheld from the shared shard log and asserted here instead, so // a probe that stopped running goes red rather than merely quiet. - expect(noise.silentChannels()).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise.tablesSeen()).toEqual([]); } finally { try { await (kernel as any)?.stop?.(); } catch { /* noop */ } } diff --git a/packages/runtime/src/expand-nested-fields-join-key.integration.test.ts b/packages/runtime/src/expand-nested-fields-join-key.integration.test.ts index 2c8e8b81b3d..88865cad6dd 100644 --- a/packages/runtime/src/expand-nested-fields-join-key.integration.test.ts +++ b/packages/runtime/src/expand-nested-fields-join-key.integration.test.ts @@ -85,6 +85,8 @@ const TASK = { * but the driver and the engine each log the fault on the way out. Withheld and * asserted rather than muted; `expected-read-refusal-noise.ts` says why. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const ABSENT_TENANCY_TABLE = 'sys_organization'; describe('#7537 expand with a nested `fields` that omits the join key (REAL SqlDriver)', () => { @@ -101,7 +103,10 @@ describe('#7537 expand with a nested `fields` that omits the join key (REAL SqlD // failure here can never leave the engine running. Every test in this file // boots and writes, so the probe fires for each of them: this holds for a // single `-t` run as well as for the whole file. - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); noise = null; }); diff --git a/packages/runtime/src/expected-read-refusal-noise.channel-asymmetry.test.ts b/packages/runtime/src/expected-read-refusal-noise.channel-asymmetry.test.ts index bb2743f53e2..3bf01c8198f 100644 --- a/packages/runtime/src/expected-read-refusal-noise.channel-asymmetry.test.ts +++ b/packages/runtime/src/expected-read-refusal-noise.channel-asymmetry.test.ts @@ -158,6 +158,15 @@ async function probeRead( await kernel.use(new ObjectQLPlugin()); await kernel.bootstrap(); capture.captureEngine(kernel.getService('objectql')); + // [#21516] The engine refuses a name its registry does not hold before any + // driver, so the probe object is REGISTERED — after boot, so no schema sync + // provisions it. The read then reaches the driver and the table is absent: + // the real refusal whose two channels this file measures. + kernel.getService<{ registry: { registerObject(o: unknown): void } }>('objectql').registry.registerObject({ + name: table, + label: table, + fields: { title: { name: 'title', type: 'text' } }, + }); const data = kernel.getService<{ find(o: string): Promise }>('data'); try { diff --git a/packages/runtime/src/package-list-commits-org-scope.integration.test.ts b/packages/runtime/src/package-list-commits-org-scope.integration.test.ts index 0bd185005b9..30daf234e3d 100644 --- a/packages/runtime/src/package-list-commits-org-scope.integration.test.ts +++ b/packages/runtime/src/package-list-commits-org-scope.integration.test.ts @@ -106,6 +106,8 @@ const OTHER_ORG = 'org_other'; * each log the read on the way out. Withheld and asserted rather than muted; * `expected-read-refusal-noise.ts` says why. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const UNBOUND_PROBE_OBJECT = 'anything'; let cleanup: Array<() => void> = []; @@ -119,7 +121,10 @@ afterEach(() => { // failure here can never leave an engine running. Every test in this file publishes at // least once, so the probe fires for each of them: this holds for a single // `-t` run as well as for the whole file. - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); noise = null; }); diff --git a/packages/runtime/src/package-revert-commit-attribution-org-scope.integration.test.ts b/packages/runtime/src/package-revert-commit-attribution-org-scope.integration.test.ts index 629e26de5fe..52b13a69c0e 100644 --- a/packages/runtime/src/package-revert-commit-attribution-org-scope.integration.test.ts +++ b/packages/runtime/src/package-revert-commit-attribution-org-scope.integration.test.ts @@ -97,6 +97,8 @@ const OTHER_ORG = 'org_other'; * each log the read on the way out. Withheld and asserted rather than muted; * `expected-read-refusal-noise.ts` says why. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const UNBOUND_PROBE_OBJECT = 'anything'; let cleanup: Array<() => void> = []; @@ -110,7 +112,10 @@ afterEach(() => { // failure here can never leave an engine running. Every test in this file publishes at // least once, so the probe fires for each of them: this holds for a single // `-t` run as well as for the whole file. - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); noise = null; }); diff --git a/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts b/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts index 963874e8f6c..dc64e68b8ee 100644 --- a/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts +++ b/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts @@ -124,6 +124,8 @@ const OTHER_ORG = 'org_other'; * each log the read on the way out. Withheld and asserted rather than muted; * `expected-read-refusal-noise.ts` says why. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const UNBOUND_PROBE_OBJECT = 'anything'; let cleanup: Array<() => void> = []; @@ -137,7 +139,10 @@ afterEach(() => { // failure here can never leave an engine running. Every test in this file publishes at // least once, so the probe fires for each of them: this holds for a single // `-t` run as well as for the whole file. - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); noise = null; }); diff --git a/packages/runtime/src/preserve-audit-real-driver.integration.test.ts b/packages/runtime/src/preserve-audit-real-driver.integration.test.ts index 8cb010df0a2..b0bf3a75dad 100644 --- a/packages/runtime/src/preserve-audit-real-driver.integration.test.ts +++ b/packages/runtime/src/preserve-audit-real-driver.integration.test.ts @@ -54,6 +54,8 @@ const TICKET = { * but the driver and the engine each log the fault on the way out. Withheld and * asserted rather than muted; `expected-read-refusal-noise.ts` says why. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const ABSENT_TENANCY_TABLE = 'sys_organization'; describe('preserveAudit end-to-end on a REAL SqlDriver (#3493 / #3549)', () => { @@ -70,7 +72,10 @@ describe('preserveAudit end-to-end on a REAL SqlDriver (#3493 / #3549)', () => { // failure here can never leave the engine running. Every test in this file // boots and writes, so the probe fires for each of them: this holds for a // single `-t` run as well as for the whole file. - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); noise = null; }); diff --git a/packages/runtime/src/sandbox/hook-input-writeback-key-set.integration.test.ts b/packages/runtime/src/sandbox/hook-input-writeback-key-set.integration.test.ts index 8c9caf56bd9..b3e7dddad49 100644 --- a/packages/runtime/src/sandbox/hook-input-writeback-key-set.integration.test.ts +++ b/packages/runtime/src/sandbox/hook-input-writeback-key-set.integration.test.ts @@ -82,6 +82,8 @@ const TASK = { const STAMP = '2026-09-03T09:00:00.000Z'; const EARLIER = '2026-01-01T00:00:00.000Z'; +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const ABSENT_TENANCY_TABLE = 'sys_organization'; /** @@ -222,7 +224,10 @@ describe('#14758 — the sandbox write-back carries the keys the body wrote', () expect(open.status).toBe('open'); expect(open.completed_at ?? null).toBeNull(); - expect(noise.silentChannels()).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise.tablesSeen()).toEqual([]); }, 60000, ); @@ -253,7 +258,10 @@ describe('#14758 — the sandbox write-back carries the keys the body wrote', () expect(already.completed_at).toBe(EARLIER); expect(open.completed_at ?? null).toBeNull(); - expect(noise.silentChannels()).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise.tablesSeen()).toEqual([]); }, 60000); it('a row-invariant sandboxed DELETE still propagates (the absence-from-dump leg)', async () => { @@ -278,6 +286,9 @@ describe('#14758 — the sandbox write-back carries the keys the body wrote', () expect(open.status).toBe('done'); expect(already.status).toBe('done'); - expect(noise.silentChannels()).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise.tablesSeen()).toEqual([]); }, 60000); }); diff --git a/packages/runtime/src/sandbox/nested-write-real-sqlite.integration.test.ts b/packages/runtime/src/sandbox/nested-write-real-sqlite.integration.test.ts index eb53d7f6b11..31f3e045706 100644 --- a/packages/runtime/src/sandbox/nested-write-real-sqlite.integration.test.ts +++ b/packages/runtime/src/sandbox/nested-write-real-sqlite.integration.test.ts @@ -81,6 +81,8 @@ const ROLLUP_HOOK = { * the driver and the engine each log it on the way out. Withheld and asserted * below rather than muted; the module header explains why. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const ABSENT_TENANCY_TABLE = 'sys_organization'; describe('#1867 nested cross-object write — REAL SqlDriver (better-sqlite3, on-disk)', () => { @@ -136,6 +138,9 @@ describe('#1867 nested cross-object write — REAL SqlDriver (better-sqlite3, on // there as a real failure; they are withheld now and asserted here. If the // probe stops running, or `sys_organization` starts resolving, the log goes // quiet AND this goes red — the failure a bare `console` mute would hide. - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); }, 30000); }); diff --git a/packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts b/packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts index 140ecf1f6c3..d4fd3165bf3 100644 --- a/packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts +++ b/packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts @@ -103,6 +103,8 @@ const PROBE_SOURCE = ` ctx.log.info('probe', o); `; +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const ABSENT_TENANCY_TABLE = 'sys_organization'; describe('#11552 — a shipped body observes the per-row dispatch signal and the D2 options projection', () => { @@ -214,6 +216,9 @@ describe('#11552 — a shipped body observes the per-row dispatch signal and the expect(single[0].optionsMulti).not.toBe(true); // [commit 13a6cb4ad] Withheld-noise pin, same as the sibling real-SQLite harness. - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); }, 30000); }); diff --git a/packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts b/packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts index 9f74662ad77..32be7c342a8 100644 --- a/packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts +++ b/packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts @@ -81,6 +81,8 @@ const PROBE_SOURCE = ` }); `; +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const ABSENT_TENANCY_TABLE = 'sys_organization'; describe('#13644 — a shipped body observes ctx.referentialFieldClear across the sandbox boundary', () => { @@ -170,6 +172,9 @@ describe('#13644 — a shipped body observes ctx.referentialFieldClear across th expect(cleared.account).toBeNull(); // [commit 13a6cb4ad] Withheld-noise pin, same as the sibling harnesses. - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); }, 30000); }); diff --git a/packages/runtime/src/seed-loader-driver-text-real-driver.integration.test.ts b/packages/runtime/src/seed-loader-driver-text-real-driver.integration.test.ts index 65deea86966..597d68835b6 100644 --- a/packages/runtime/src/seed-loader-driver-text-real-driver.integration.test.ts +++ b/packages/runtime/src/seed-loader-driver-text-real-driver.integration.test.ts @@ -94,6 +94,8 @@ function metadataFor(objects: any[]) { * Withheld and asserted rather than muted; `expected-read-refusal-noise.ts` * says why. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const ABSENT_TENANCY_TABLE = 'sys_organization'; describe('[#8442] a REAL driver constraint violation is withheld from the seed response', () => { @@ -109,7 +111,10 @@ describe('[#8442] a REAL driver constraint violation is withheld from the seed r // [commit 13a6cb4ad] The capture is a PIN, not a mute — asserted after teardown so a // failure here can never leave the engine running. The single test in this // file boots and writes, so the probe fires for it. - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); noise = null; }); diff --git a/packages/runtime/src/seed-multi-value-lookup-real-driver.integration.test.ts b/packages/runtime/src/seed-multi-value-lookup-real-driver.integration.test.ts index 4d5e23c5b89..20751054fb9 100644 --- a/packages/runtime/src/seed-multi-value-lookup-real-driver.integration.test.ts +++ b/packages/runtime/src/seed-multi-value-lookup-real-driver.integration.test.ts @@ -71,6 +71,8 @@ const SEEDS = [ * but the driver and the engine each log the fault on the way out. Withheld and * asserted rather than muted; `expected-read-refusal-noise.ts` says why. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, so +// this read no longer reaches the driver and nothing above is logged; the pin asserts that. const ABSENT_TENANCY_TABLE = 'sys_organization'; describe('multi-value lookup seeds on a REAL SqlDriver (framework#3911)', () => { @@ -87,7 +89,10 @@ describe('multi-value lookup seeds on a REAL SqlDriver (framework#3911)', () => // failure here can never leave the engine running. Every test in this file // boots and writes, so the probe fires for each of them: this holds for a // single `-t` run as well as for the whole file. - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // [#21516] Quiet by construction now: the engine refuses a name its registry does not + // hold before any driver, so the declared refusal no longer occurs. The capture stays + // declared (a returning read is still withheld and counted) and this asserts nothing was. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); noise = null; }); From b1ebc0aa752f3ceb0b1ded7a3ad6b7c00fd0d254 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:44:53 +0000 Subject: [PATCH 08/15] test(plugins,services): harnesses register the objects their platform readers resolve The engine now refuses an object name its registry does not hold, so partial compositions register what a deployment's plugins register: the authz resolver's read set (left unprovisioned, so it still reads "no grants"), the settings service's secret and audit objects, and the approvals fixture's two expected-absent probes (unprovisioned, so its withheld-refusal pin is unchanged). Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .../src/status-mirror-cascade.integration.test.ts | 12 ++++++++++++ .../src/position-catalog-refusal.test.ts | 10 ++++++++++ .../src/private-credential-row-scope.test.ts | 14 ++++++++++++++ ...ls-variable-root-comparand-fails-closed.test.ts | 14 ++++++++++++++ .../src/scim-projection-row-scope.test.ts | 14 ++++++++++++++ .../src/config-change-audit.test.ts | 10 +++++++++- .../src/settings-engine-bind-window.test.ts | 12 +++++++++--- 7 files changed, 82 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts b/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts index ce8a7a6aaf7..a3bf8ac1ffe 100644 --- a/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts @@ -46,6 +46,7 @@ import { ApprovalService } from './approval-service.js'; import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; +import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; // [commit c28e4cfae] `@objectstack/runtime`'s shared expected-noise capture. This import // escapes the package on PURPOSE, so it is DECLARED rather than left for CI to // discover: `CROSS_PACKAGE_TEST_INPUTS` in @@ -313,6 +314,17 @@ describe('an approval decision cascades as the deciding user (#3783)', () => { // Real DDL for all four objects — including the three sys_approval_* tables // the ApprovalService writes through. await objectql.syncSchemas(); + // [#21516] The two probes this fixture EXPECTS to be refused (above) are + // registered here, AFTER the DDL, so they stay unprovisioned. The engine + // refuses a name its registry does not hold before any driver, so the + // single-tenant org probe and the delegation lookup reach the driver — and + // its missing-table refusal — only through registered objects, as they do + // in production (the approvals plugin registers `sys_approval_delegation`). + objectql.registry.registerObject(SysApprovalDelegation as any, 'approvals-test', 'approvals-test'); + objectql.registry.registerObject( + { name: 'sys_organization', label: 'Organization', fields: { name: { type: 'text', label: 'Name' } } } as any, + 'approvals-test', + ); automation.registerFlow('on_approved', onApprovedFlow as any); svc = new ApprovalService({ engine: objectql }); diff --git a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts index f45666f9480..652b9da0bfc 100644 --- a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts +++ b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts @@ -31,6 +31,7 @@ import { resolveThrownHttpError } from '@objectstack/types'; import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; +import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; import { SysPosition } from './objects/sys-position.object.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysPermissionSet } from './objects/sys-permission-set.object.js'; @@ -120,6 +121,15 @@ async function boot(opts: { walled?: boolean } = {}) { objects: [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, QA_INQUIRY], } as any); await engine.syncSchemas(); + // [#21516] The authz resolver reads these on every grant resolution; in a + // deployment the auth and security plugins register them. This harness + // composes neither, so the ones its app does not declare are registered + // here, AFTER the DDL above, and stay unprovisioned: the resolver reads a + // missing table and answers "no grants", exactly as it did when the engine + // still handed an unregistered name to the driver (which it now refuses). + for (const o of [SysUser, SysMember, SysPosition, SysUserPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); + } engines.push(engine); const warn = vi.fn(); diff --git a/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts b/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts index 0a9728c43c6..16cc98e060b 100644 --- a/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts +++ b/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts @@ -41,6 +41,11 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SysJwks, SysMember, SysUser, SysVerification } from '@objectstack/platform-objects/identity'; import { SecurityPlugin } from './security-plugin.js'; +import { SysPosition } from './objects/sys-position.object.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; const CREDENTIAL_OBJECTS = ['sys_verification', 'sys_jwks'] as const; type CredentialObject = (typeof CREDENTIAL_OBJECTS)[number]; @@ -105,6 +110,15 @@ async function boot(shape: Shape): Promise { objects: SCHEMAS, } as never); await engine.syncSchemas(); + // [#21516] The authz resolver reads these on every grant resolution; in a + // deployment the auth and security plugins register them. This harness + // composes neither, so the ones its app does not declare are registered + // here, AFTER the DDL above, and stay unprovisioned: the resolver reads a + // missing table and answers "no grants", exactly as it did when the engine + // still handed an unregistered name to the driver (which it now refuses). + for (const o of [SysUser, SysMember, SysPosition, SysUserPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); + } const services: Record = { ...(shape.posture === 'isolated' ? { 'org-scoping': { name: 'com.objectstack.org-scoping' } } : {}), diff --git a/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts b/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts index b55676268ce..8848913fd7a 100644 --- a/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts +++ b/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts @@ -28,6 +28,11 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SysMember, SysUser } from '@objectstack/platform-objects/identity'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; +import { SysPosition } from './objects/sys-position.object.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { RLS_DENY_FILTER } from './rls-compiler.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; @@ -79,6 +84,15 @@ async function boot(policies: Policy[]) { ], } as never); await engine.syncSchemas(); + // [#21516] The authz resolver reads these on every grant resolution; in a + // deployment the auth and security plugins register them. This harness + // composes neither, so the ones its app does not declare are registered + // here, AFTER the DDL above, and stay unprovisioned: the resolver reads a + // missing table and answers "no grants", exactly as it did when the engine + // still handed an unregistered name to the driver (which it now refuses). + for (const o of [SysUser, SysMember, SysPosition, SysUserPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); + } engines.push(engine); const crud = { allowRead: true, allowCreate: true, allowEdit: true }; diff --git a/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts b/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts index eb34a837fef..2d22032bae6 100644 --- a/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts +++ b/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts @@ -53,6 +53,11 @@ import { SysUser, } from '@objectstack/platform-objects/identity'; import { SecurityPlugin } from './security-plugin.js'; +import { SysPosition } from './objects/sys-position.object.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; /** The SCIM tables that name the platform user each row is about. */ const USER_KEYED = [ @@ -181,6 +186,15 @@ async function boot(shape: Shape): Promise { objects: SCHEMAS, } as never); await engine.syncSchemas(); + // [#21516] The authz resolver reads these on every grant resolution; in a + // deployment the auth and security plugins register them. This harness + // composes neither, so the ones its app does not declare are registered + // here, AFTER the DDL above, and stay unprovisioned: the resolver reads a + // missing table and answers "no grants", exactly as it did when the engine + // still handed an unregistered name to the driver (which it now refuses). + for (const o of [SysUser, SysMember, SysPosition, SysUserPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); + } const services: Record = { ...(shape.posture === 'isolated' ? { 'org-scoping': { name: 'com.objectstack.org-scoping' } } : {}), diff --git a/packages/services/service-settings/src/config-change-audit.test.ts b/packages/services/service-settings/src/config-change-audit.test.ts index d04bb394f1e..8f34bff5c23 100644 --- a/packages/services/service-settings/src/config-change-audit.test.ts +++ b/packages/services/service-settings/src/config-change-audit.test.ts @@ -40,7 +40,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysSecret, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; import type { SettingsManifest } from '@objectstack/spec/system'; import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; import { @@ -228,6 +228,10 @@ async function bootPlugin(opts: BootOptions = {}) { await engine.init(); engine.registry.registerObject(SysSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysSettingAudit as any, OWNER_PACKAGE); + // [#21516] A secret-typed setting writes through `sys_secret`; the engine + // refuses a name its registry does not hold, so the harness registers it as + // a boot does (platform-objects owns it). + engine.registry.registerObject(SysSecret as any, OWNER_PACKAGE); // [#18368] The mount point — see `LEDGER_MOUNT_STANDIN`. if (opts.ledgerMounted !== false) { engine.registry.registerObject(LEDGER_MOUNT_STANDIN as any, OWNER_PACKAGE); @@ -578,6 +582,10 @@ describe('#8145 — a refused write emits NO config_change row', () => { await engine.init(); engine.registry.registerObject(SysSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysSettingAudit as any, OWNER_PACKAGE); + // [#21516] A secret-typed setting writes through `sys_secret`; the engine + // refuses a name its registry does not hold, so the harness registers it as + // a boot does (platform-objects owns it). + engine.registry.registerObject(SysSecret as any, OWNER_PACKAGE); // [#18368] This case asserts the sink writes on its NON-VACUITY leg, so the // ledger has to be mounted — see `LEDGER_MOUNT_STANDIN`. engine.registry.registerObject(LEDGER_MOUNT_STANDIN as any, OWNER_PACKAGE); diff --git a/packages/services/service-settings/src/settings-engine-bind-window.test.ts b/packages/services/service-settings/src/settings-engine-bind-window.test.ts index 6717b9f7ffa..394bbe4419f 100644 --- a/packages/services/service-settings/src/settings-engine-bind-window.test.ts +++ b/packages/services/service-settings/src/settings-engine-bind-window.test.ts @@ -55,7 +55,7 @@ import { describe, expect, it } from 'vitest'; import { LiteKernel } from '@objectstack/core'; import type { Plugin, PluginContext } from '@objectstack/core'; import { ObjectQL } from '@objectstack/objectql'; -import { SysSecret, SysSetting } from '@objectstack/platform-objects/system'; +import { SysSecret, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; import type { SettingsManifest } from '@objectstack/spec/system'; import { SettingsService } from './settings-service.js'; import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; @@ -229,7 +229,10 @@ async function bootKernel(opts: { withEngine?: boolean } = {}) { const engine = new ObjectQL(); engine.registerDriver(driver, true); await engine.init(); - for (const o of [SysSetting, SysSecret]) engine.registry.registerObject(o as any, OWNER_PACKAGE); + // [#21516] The settings service writes its audit row through + // `sys_setting_audit`; the engine refuses a name its registry does not hold, + // so the harness registers it beside the two it already did, as a boot does. + for (const o of [SysSetting, SysSecret, SysSettingAudit]) engine.registry.registerObject(o as any, OWNER_PACKAGE); const probe = new ReadyHookFromInitPlugin(); const kernel = new LiteKernel({ logger: { level: 'error' } as never }); @@ -365,7 +368,10 @@ describe('engine-less callers outside the window observe nothing new', () => { const engine = new ObjectQL(); engine.registerDriver(driver, true); await engine.init(); - for (const o of [SysSetting, SysSecret]) engine.registry.registerObject(o as any, OWNER_PACKAGE); + // [#21516] The settings service writes its audit row through + // `sys_setting_audit`; the engine refuses a name its registry does not hold, + // so the harness registers it beside the two it already did, as a boot does. + for (const o of [SysSetting, SysSecret, SysSettingAudit]) engine.registry.registerObject(o as any, OWNER_PACKAGE); const svc = new SettingsService({ env: {}, engineBindPending: true }); svc.registerManifest(probeManifest); From a1c0885b73e33b504deb90c9afef08bac604d14f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:46:27 +0000 Subject: [PATCH 09/15] test(rest): harnesses register the stored-metadata family the protocol reads The engine now refuses an object name its registry does not hold, so the real-engine import/export and classification harnesses register the family after their DDL; an unprovisioned store still answers the driver's own "no such table", which those pins classify. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .../src/data-temporal-year-range-message.test.ts | 7 +++++++ packages/rest/src/export-business-timezone.test.ts | 7 +++++++ packages/rest/src/export-date-year-pad.test.ts | 7 +++++++ packages/rest/src/export-integration.test.ts | 13 +++++++++++++ packages/rest/src/import-business-timezone.test.ts | 7 +++++++ .../rest/src/import-date-cell-iso-real-day.test.ts | 7 +++++++ .../rest/src/import-datetime-year-below-100.test.ts | 7 +++++++ packages/rest/src/import-integration.test.ts | 7 +++++++ packages/rest/src/import-job-integration.test.ts | 7 +++++++ .../rest/src/import-number-thousands-group.test.ts | 7 +++++++ packages/rest/src/import-template-route.test.ts | 13 +++++++++++++ packages/rest/src/import-time-cell-fraction.test.ts | 7 +++++++ .../rest/src/rest-5xx-message-sanitization.test.ts | 7 +++++++ .../rest/src/rest-unknown-object-heuristic.test.ts | 13 +++++++++++++ 14 files changed, 116 insertions(+) diff --git a/packages/rest/src/data-temporal-year-range-message.test.ts b/packages/rest/src/data-temporal-year-range-message.test.ts index c820cb0ee9d..79adc723ecd 100644 --- a/packages/rest/src/data-temporal-year-range-message.test.ts +++ b/packages/rest/src/data-temporal-year-range-message.test.ts @@ -35,6 +35,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { RestServer } from './rest-server'; const OBJECT = 'rest_year_message_20846'; @@ -106,6 +107,12 @@ describe('[#20846] a date / datetime refused for its year names the years — PO await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); diff --git a/packages/rest/src/export-business-timezone.test.ts b/packages/rest/src/export-business-timezone.test.ts index cde9ea5b44d..3ca33dc54ef 100644 --- a/packages/rest/src/export-business-timezone.test.ts +++ b/packages/rest/src/export-business-timezone.test.ts @@ -34,6 +34,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { RestServer } from './rest-server.js'; import { formatCellValue, formatRowCells, formatRowForJson } from './export-format.js'; import type { ExportFieldMeta } from './export-format.js'; @@ -209,6 +210,12 @@ async function boot(timezone?: string) { await engine.syncSchemas(); await engine.insert('shift', { id: '1', scanned_at: CROSS_MONTH_UTC, due: '2026-08-01' }); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer( createMockServer() as any, diff --git a/packages/rest/src/export-date-year-pad.test.ts b/packages/rest/src/export-date-year-pad.test.ts index 7e8d9035868..23100ff52ad 100644 --- a/packages/rest/src/export-date-year-pad.test.ts +++ b/packages/rest/src/export-date-year-pad.test.ts @@ -36,6 +36,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { RestServer } from './rest-server.js'; import { formatCellValue } from './export-format.js'; import type { ExportFieldMeta } from './export-format.js'; @@ -173,6 +174,12 @@ async function boot(timezone: string | undefined, engines: ObjectQL[]) { await engine.init(); engine.registerObject(LEDGER as any); await engine.syncSchemas(); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ userId: 'test-user', ...(timezone ? { timezone } : {}) }); diff --git a/packages/rest/src/export-integration.test.ts b/packages/rest/src/export-integration.test.ts index 10660367b8b..28d33d2194e 100644 --- a/packages/rest/src/export-integration.test.ts +++ b/packages/rest/src/export-integration.test.ts @@ -36,6 +36,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { maskFieldValue } from '@objectstack/plugin-security'; import { RestServer } from './rest-server'; import { loadXlsxWorkbook } from './xlsx-test-loader.js'; @@ -140,6 +141,12 @@ async function boot() { await engine.insert('task', { id: '1', title: '写代码', done: true, priority: 'high', due: '2026-06-30T00:00:00.000Z', owner: 'u1' }); await engine.insert('task', { id: '2', title: '写文档', done: false, priority: 'low', due: '2026-07-01T00:00:00.000Z', owner: 'u2' }); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); @@ -374,6 +381,12 @@ describe('export route — FLS column projection via getReadableFields (#3547)', { id: '2', title: '写文档', done: false, priority: 'low', due: '2026-07-01T00:00:00.000Z', owner: 'u1' }, ]; for (const t of tasks) await engine.insert('task', t); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); // 16th positional ctor arg is `securityServiceProvider`. const securityServiceProvider = async () => ({ getReadableFields: opts.getReadableFields }); diff --git a/packages/rest/src/import-business-timezone.test.ts b/packages/rest/src/import-business-timezone.test.ts index eabdcf06af5..72cdc6da6a8 100644 --- a/packages/rest/src/import-business-timezone.test.ts +++ b/packages/rest/src/import-business-timezone.test.ts @@ -39,6 +39,7 @@ import ExcelJS from 'exceljs'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { RestServer } from './rest-server.js'; import { parseDateCell, coerceFieldValue, coerceRow } from '@objectstack/core'; import { parseXlsxToRows } from './import-prepare.js'; @@ -387,6 +388,12 @@ async function boot(timezone?: string) { await engine.syncSchemas(); await engine.insert('shift', { id: '1', scanned_at: CROSS_MONTH_UTC, due: '2026-08-01' }); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ diff --git a/packages/rest/src/import-date-cell-iso-real-day.test.ts b/packages/rest/src/import-date-cell-iso-real-day.test.ts index 85aad3bec34..b51f9c111c1 100644 --- a/packages/rest/src/import-date-cell-iso-real-day.test.ts +++ b/packages/rest/src/import-date-cell-iso-real-day.test.ts @@ -48,6 +48,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { RestServer } from './rest-server'; import { loadExcelJs } from './xlsx-module.js'; @@ -139,6 +140,12 @@ async function boot() { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); diff --git a/packages/rest/src/import-datetime-year-below-100.test.ts b/packages/rest/src/import-datetime-year-below-100.test.ts index dc636797c41..feed1c81d2e 100644 --- a/packages/rest/src/import-datetime-year-below-100.test.ts +++ b/packages/rest/src/import-datetime-year-below-100.test.ts @@ -47,6 +47,7 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { RestServer } from './rest-server.js'; import { parseDateCell } from '@objectstack/core'; @@ -135,6 +136,12 @@ async function boot(timezone: string | undefined, engines: ObjectQL[]) { await engine.init(); engine.registerObject(LEDGER as any); await engine.syncSchemas(); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ userId: 'test-user', ...(timezone ? { timezone } : {}) }); diff --git a/packages/rest/src/import-integration.test.ts b/packages/rest/src/import-integration.test.ts index 43afdae950b..8ac099968a2 100644 --- a/packages/rest/src/import-integration.test.ts +++ b/packages/rest/src/import-integration.test.ts @@ -26,6 +26,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { MetadataManager } from '@objectstack/metadata'; import { RestServer } from './rest-server'; import { loadExcelJs } from './xlsx-module.js'; @@ -160,6 +161,12 @@ async function boot(services?: Map) { await engine.insert('user', { id: 'u1', name: '张三', email: 'zhang@x.com' }); await engine.insert('user', { id: 'u2', name: '李四', email: 'li@x.com' }); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation( engine as any, services ? () => services : undefined, diff --git a/packages/rest/src/import-job-integration.test.ts b/packages/rest/src/import-job-integration.test.ts index 51f09867bc7..f947af08027 100644 --- a/packages/rest/src/import-job-integration.test.ts +++ b/packages/rest/src/import-job-integration.test.ts @@ -24,6 +24,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { SysImportJob } from '@objectstack/platform-objects/audit'; // [commit a92b1793c] The ONE definition of the async-import row ceiling. The pin below reads it // from here so that moving it is observable at the enforcement point. @@ -102,6 +103,12 @@ async function boot(decorateDriver?: (driver: any) => void) { // Real DDL for both tables before the first job row is written. await engine.syncSchemas(); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); diff --git a/packages/rest/src/import-number-thousands-group.test.ts b/packages/rest/src/import-number-thousands-group.test.ts index 3820e2a21b1..78c5fedb851 100644 --- a/packages/rest/src/import-number-thousands-group.test.ts +++ b/packages/rest/src/import-number-thousands-group.test.ts @@ -37,6 +37,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { RestServer } from './rest-server'; const OBJECT = 'import_thousands_20497'; @@ -94,6 +95,12 @@ async function boot() { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); diff --git a/packages/rest/src/import-template-route.test.ts b/packages/rest/src/import-template-route.test.ts index 3ba4de75e71..a5a23b16198 100644 --- a/packages/rest/src/import-template-route.test.ts +++ b/packages/rest/src/import-template-route.test.ts @@ -18,6 +18,7 @@ import { createHash } from 'node:crypto'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { RestServer } from './rest-server'; import { parseXlsxToRows } from './import-prepare.js'; import { isTemplateRequired, templateInsertDefault } from './import-template.js'; @@ -124,6 +125,12 @@ async function boot(opts: BootOptions = {}) { for (const o of [ACCOUNT, LINE, DEAL]) engine.registry.registerObject(o as any); await engine.syncSchemas(); await engine.insert('account', { id: 'a1', name: 'Acme' }); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); const findData = vi.spyOn(protocol as any, 'findData'); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); @@ -702,6 +709,12 @@ async function bootExportFixture(security?: Record) { await engine.insert('user', { id: 'u2', name: '李四' }); await engine.insert('task', { id: '1', title: '写代码', done: true, priority: 'high', due: '2026-06-30T00:00:00.000Z', owner: 'u1' }); await engine.insert('task', { id: '2', title: '写文档', done: false, priority: 'low', due: '2026-07-01T00:00:00.000Z', owner: 'u2' }); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); const findData = vi.spyOn(protocol as any, 'findData'); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); diff --git a/packages/rest/src/import-time-cell-fraction.test.ts b/packages/rest/src/import-time-cell-fraction.test.ts index 9ca8f5ead24..63e889d9e9b 100644 --- a/packages/rest/src/import-time-cell-fraction.test.ts +++ b/packages/rest/src/import-time-cell-fraction.test.ts @@ -58,6 +58,7 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { RestServer } from './rest-server.js'; import { parseDateCell } from '@objectstack/core'; @@ -201,6 +202,12 @@ for (const cell of CELLS) { engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); diff --git a/packages/rest/src/rest-5xx-message-sanitization.test.ts b/packages/rest/src/rest-5xx-message-sanitization.test.ts index 02141de03e3..2b826deee0b 100644 --- a/packages/rest/src/rest-5xx-message-sanitization.test.ts +++ b/packages/rest/src/rest-5xx-message-sanitization.test.ts @@ -61,6 +61,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { RestServer } from './rest-server'; @@ -245,6 +246,12 @@ async function bootRealProtocol(dbError: string) { const engine = new ObjectQL(); engine.registerDriver(failingDriver(dbError), true); await engine.init(); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); return mountRest(protocol as any); } diff --git a/packages/rest/src/rest-unknown-object-heuristic.test.ts b/packages/rest/src/rest-unknown-object-heuristic.test.ts index 8dd61ea3eec..5a80283d6c7 100644 --- a/packages/rest/src/rest-unknown-object-heuristic.test.ts +++ b/packages/rest/src/rest-unknown-object-heuristic.test.ts @@ -56,6 +56,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { mapDataError, RestServer } from './rest-server'; // [#17865] This file observes the REST fault log, so it declares the level it @@ -134,6 +135,12 @@ async function bootRealProtocol(dbError: string) { const engine = new ObjectQL(); engine.registerDriver(failingDriver(dbError), true); await engine.init(); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); return mountRest(protocol as any); } @@ -250,6 +257,12 @@ describe('[#5462] sys_metadata unavailable is a fault, not a missing object', () const engine = new ObjectQL(); engine.registerDriver(failingDriver(SQLITE_NO_TABLE), true); await engine.init(); + // [#21516] The protocol reads the stored-metadata family; the engine refuses a + // name its registry does not hold, so the harness registers the family as a boot + // does — after the DDL, so an unprovisioned store still answers "no such table". + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol: any = new ObjectStackProtocolImplementation(engine as any); let seen: any; From 61add5aede610f760b5e106d234361f8ffeb75aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:11:37 +0000 Subject: [PATCH 10/15] test(cli): the served-boot control witnesses its hooks' reads by their answer The control's driver lines existed only because the hooks read objects the boot never declared through the engine's raw-table fall-through; the engine now refuses those names before any driver. Each probe read now records its answer, and the control asserts OBJECT_NOT_FOUND and no driver line. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- ...chema-migrate.host-composition.integration.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts b/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts index 2009737a968..10cefa49f57 100644 --- a/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts @@ -908,7 +908,8 @@ describe('a plan runs no app lifecycle hook (#21054)', () => { " ctx.hook('kernel:bootstrapped', async () => {", " appendFileSync(LOG, 'app|kernel:bootstrapped\\n');", ` for (const object of ${JSON.stringify(PROBE_TABLES)}) {`, - " try { await ctx.ql.find(object, { where: { name: 'x' }, limit: 1, context: SYS }); } catch { /* answered */ }", + " try { await ctx.ql.find(object, { where: { name: 'x' }, limit: 1, context: SYS }); appendFileSync(LOG, `app|read|${object}|ok\\n`); }", + " catch (e: any) { appendFileSync(LOG, `app|read|${object}|${e && e.code}\\n`); }", ' }', ' });', '};', @@ -963,9 +964,15 @@ describe('a plan runs no app lifecycle hook (#21054)', () => { expect(log).toContain('app|onEnable'); expect(log).toContain('app|kernel:bootstrapped'); expect(log).toContain('host|kernel:bootstrapped'); + // [#21516] The engine now refuses a name its registry does not hold + // before any driver, so a read of an object this boot never declared is + // answered `OBJECT_NOT_FOUND` and no driver line is printed. The witness + // that the hooks' reads were ISSUED — what the plan legs below prove + // absent — is therefore each read's recorded answer, not a driver line. for (const table of PROBE_TABLES) { - expect(captured.lines.some((l) => l.includes(`'${table}'`))).toBe(true); + expect(log).toContain(`app|read|${table}|OBJECT_NOT_FOUND`); } + expect(captured.lines.filter((l) => PROBE_TABLES.some((t) => l.includes(`'${t}'`)))).toEqual([]); } finally { captured.restore(); await stack.shutdown(); From ddcfe2029027021c5f19daab114bbcf3dd0beb0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:24:41 +0000 Subject: [PATCH 11/15] test(dogfood): the gate premise reads ground truth at the driver; the engine refuses Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .../test/registry-gate-wiring.dogfood.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/qa/dogfood/test/registry-gate-wiring.dogfood.test.ts b/packages/qa/dogfood/test/registry-gate-wiring.dogfood.test.ts index f17b014ba72..16999ac283b 100644 --- a/packages/qa/dogfood/test/registry-gate-wiring.dogfood.test.ts +++ b/packages/qa/dogfood/test/registry-gate-wiring.dogfood.test.ts @@ -103,16 +103,23 @@ describe('dogfood: the object-existence gates are actually WIRED (#3770, #3867, // Without this, every 404 below could be passing for the wrong reason. // ───────────────────────────────────────────────────────────── - it('premise: the unregistered name IS a real table the engine can read', async () => { - // Straight through the ENGINE, which is deliberately ungated — #3770 put - // the gate at the protocol ingress, the external API boundary, precisely so - // internal callers (hooks, flows, migrations, raw ObjectQL) keep working. - // So this both establishes ground truth and pins that boundary choice. + it('premise: the unregistered name IS a real table — readable at the driver, refused by the engine', async () => { + // Ground truth through the DRIVER, the declared internal path a body + // cannot reach (host code holding the engine). Without this, every 404 + // below could be passing for the wrong reason. // eslint-disable-next-line @typescript-eslint/no-explicit-any const ql = await stack.kernel.getServiceAsync('objectql'); - const rows = (await ql.find(UNREGISTERED_BUT_REAL, { context: { isSystem: true } })) as unknown[]; + const driver = ql.getDriverForObject(UNREGISTERED_BUT_REAL); + expect(driver, 'the default driver answers the name').toBeDefined(); + const rows = (await driver.find(UNREGISTERED_BUT_REAL, {})) as unknown[]; expect(Array.isArray(rows)).toBe(true); expect(rows.length).toBeGreaterThan(0); + // [#21516] One name space with the door: the engine's in-process verbs no + // longer read a table by a name the registry does not hold — #3770's gate + // at the protocol ingress stays, and the engine now answers the same name + // with the same envelope. + await expect(ql.find(UNREGISTERED_BUT_REAL, { context: { isSystem: true } })) + .rejects.toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404 }); }); // ───────────────────────────────────────────────────────────── From 8f5595ffb819b9517045e47c59a6c1b963ef0ed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:39:35 +0000 Subject: [PATCH 12/15] test: record-change and http-conformance fixtures follow the engine's refusal; type a mock The record-change org-probe pin asserts the absent organization object is quiet by construction; the conformance stack registers the authz resolver's read set (unprovisioned) that its stubbed auth service never did; the engine.test expand mock types its parameter (test-typecheck ledger). Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- packages/objectql/src/engine.test.ts | 2 +- .../src/conformance.integration.test.ts | 77 +++++++++++++++++++ .../src/record-change-integration.test.ts | 8 +- 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index b33eec83ce6..6a45adc88e3 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -2067,7 +2067,7 @@ describe('ObjectQL Engine', () => { // (an in-process verb refuses a name it does not hold), so `task` is // registered; the REFERENCED object is not, and expand leaves the // raw id without a second driver read. - vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => (name === 'task' + vi.mocked(SchemaRegistry.getObject).mockImplementation((name: string) => (name === 'task' ? { name: 'task', fields: { assignee: { type: 'lookup', reference: 'user' } } } as any : undefined)); diff --git a/packages/qa/http-conformance/src/conformance.integration.test.ts b/packages/qa/http-conformance/src/conformance.integration.test.ts index 6de8df2acd4..ddcbbdb53e3 100644 --- a/packages/qa/http-conformance/src/conformance.integration.test.ts +++ b/packages/qa/http-conformance/src/conformance.integration.test.ts @@ -44,6 +44,82 @@ const ADAPTERS: AdapterCase[] = [ * parity) need the routes to exist. Pass false to exercise the * capability-absent posture: no mounts, shared 404. */ +/** + * [#21516] The objects core's authz resolver (`resolveUserAuthzGrants`) reads on + * every request. A deployment's auth and security plugins register them; this + * stack stubs the `auth` service instead, so they are spelled here with only the + * columns that resolver reads (no dependency edge onto either package, the + * precedent `trigger-record-change`'s integration fixture set) and registered + * AFTER boot, so no schema sync provisions them: the resolver reads missing + * tables and answers "no grants", exactly as it did when the engine still + * handed an unregistered name to the driver (which it now refuses). + */ +const authzResolverObjects = [ + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_user', + label: 'User', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + email: { name: 'email', type: 'text' as const }, + }, + }, + }, + { + owner: '@objectstack/plugin-auth', + def: { + name: 'sys_member', + label: 'Member', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + user_id: { name: 'user_id', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + role: { name: 'role', type: 'text' as const }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_position', + label: 'User Position', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + user_id: { name: 'user_id', type: 'text' as const }, + position: { name: 'position', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + user_id: { name: 'user_id', type: 'text' as const }, + permission_set_id: { name: 'permission_set_id', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, + }, + }, + { + owner: '@objectstack/plugin-security', + def: { + name: 'sys_position', + label: 'Position', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + active: { name: 'active', type: 'boolean' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, + }, + }, +] as const; + async function bootStack(makePlugin: () => any, opts: { withAnalytics?: boolean } = {}) { const kernel = new LiteKernel(); kernel.use(new ObjectQLPlugin()); @@ -97,6 +173,7 @@ async function bootStack(makePlugin: () => any, opts: { withAnalytics?: boolean // insert fails with `no such table`, which the REST error mapper turns into // a 404 OBJECT_NOT_FOUND — a routing-shaped symptom for a DDL-shaped cause. await ql.syncObjectSchema('task'); + for (const o of authzResolverObjects) ql.registry.registerObject(o.def as never, o.owner); const httpServer = kernel.getService('http.server'); return { kernel, base: `http://127.0.0.1:${httpServer.getPort!()}` }; diff --git a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts index b1b2c6b469d..3a6c88aa779 100644 --- a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts +++ b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts @@ -104,6 +104,9 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); * real console, and COUNTS what it withheld so `afterAll` can assert the * expected reads still happen. A capture nobody asserts is a mute. */ +// [#21516] The engine now refuses a name its registry does not hold before any driver, +// so the org probe asks the registry and never reads an unregistered organization +// object: this read no longer happens, and the pin below asserts exactly that. const EXPECTED_ABSENT_PROBE_TABLES = ['sys_organization'] as const; /** @@ -206,7 +209,10 @@ const noise = captureExpectedReadRefusals([...EXPECTED_ABSENT_PROBE_TABLES]); * assertion exists to make loud. */ afterAll(() => { - expect(noise.silentChannels()).toEqual([]); + // [#21516] Quiet by construction now: the declared refusal no longer occurs. The + // capture stays declared (a returning read is still withheld and counted) and + // this asserts nothing was — so a read that starts happening again turns red. + expect(noise.tablesSeen()).toEqual([]); }); /** From bab0903840271dcd2faee150a6ad4ee9e4d8bb89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 08:24:18 +0000 Subject: [PATCH 13/15] test(runtime): pin the actions-door exit for a name the registry does not resolve; changesets An action body through REST /actions reading an out-of-band table by its unregistered name now answers 404 OBJECT_NOT_FOUND for an administrator and a member, with nothing of the table in the answer; the same body on a registered name is served (the control); the data door's own 404 is the reference. Changesets: core minor (new objectNotFoundError export), objectql minor BREAKING narrowing with its ADR-0087 disposition, metadata-protocol patch, spec patch (contract docblock). Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude --- .../21516-core-object-not-found-error.md | 9 + ...21516-metadata-protocol-refusal-readers.md | 10 + .../21516-objectql-unresolved-name-refusal.md | 19 ++ .../21516-spec-judge-filter-docblock.md | 9 + ...olved-object-name.actions-door.pin.test.ts | 235 ++++++++++++++++++ 5 files changed, 282 insertions(+) create mode 100644 .changeset/21516-core-object-not-found-error.md create mode 100644 .changeset/21516-metadata-protocol-refusal-readers.md create mode 100644 .changeset/21516-objectql-unresolved-name-refusal.md create mode 100644 .changeset/21516-spec-judge-filter-docblock.md create mode 100644 packages/runtime/src/unresolved-object-name.actions-door.pin.test.ts diff --git a/.changeset/21516-core-object-not-found-error.md b/.changeset/21516-core-object-not-found-error.md new file mode 100644 index 00000000000..dc23723fe47 --- /dev/null +++ b/.changeset/21516-core-object-not-found-error.md @@ -0,0 +1,9 @@ +--- +'@objectstack/core': minor +--- + +New export `objectNotFoundError(object)`: the one `OBJECT_NOT_FOUND` envelope the data door and the engine's in-process verbs refuse an unresolved object name with + +Clause-②: yes + +`@objectstack/core` exports `objectNotFoundError(object: string): Error`. The error it returns carries `code: 'OBJECT_NOT_FOUND'`, `status: 404`, the requested name on `object`, and the message `Object '' not found`. It lives here beside `recordNotFoundError`, and for the same reason: the engine cannot import `@objectstack/metadata-protocol`, where the data door first wrote this envelope (ADR-0076 D2). The data door's object-existence gate and `@objectstack/objectql`'s resolver both build their refusal from it, so the two answer one name space with one envelope. Additive: nothing that existed before changes. diff --git a/.changeset/21516-metadata-protocol-refusal-readers.md b/.changeset/21516-metadata-protocol-refusal-readers.md new file mode 100644 index 00000000000..580917dc9f8 --- /dev/null +++ b/.changeset/21516-metadata-protocol-refusal-readers.md @@ -0,0 +1,10 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +The data door's object-existence gate builds its `OBJECT_NOT_FOUND` from the shared factory, and two best-effort readers treat the engine's refusal of their own object as the not-provisioned case + +Clause-②: no + +- `assertObjectRegistered` (the data door's object-existence gate) now throws `objectNotFoundError(object)` from `@objectstack/core`. The code, the status, the `object` field and the message are unchanged, byte for byte. +- `SeedLoaderService.resolveSoleOrganizationId` and the history counters `SysMetadataRepository` reads (`version`, `event_seq`) already answered a missing table of their own object as "nothing here yet". `@objectstack/objectql` now refuses an object name its registry does not hold with `OBJECT_NOT_FOUND` instead of reaching the driver, so each reader also answers that refusal as the same absence when the error's own `object` is the object it read. A refusal naming another object, and every other read failure, still propagate. With a registered object nothing changes. diff --git a/.changeset/21516-objectql-unresolved-name-refusal.md b/.changeset/21516-objectql-unresolved-name-refusal.md new file mode 100644 index 00000000000..3b276c6c775 --- /dev/null +++ b/.changeset/21516-objectql-unresolved-name-refusal.md @@ -0,0 +1,19 @@ +--- +'@objectstack/objectql': minor +--- + +An in-process engine verb refuses an object name the registry does not resolve, with the data door's own `OBJECT_NOT_FOUND`, instead of handing it to the driver as a raw table name + +Clause-②: no (narrowing) + + + +**BREAKING** accept-set narrowing of the engine's in-process verbs, shipped as `minor` under the repo's launch-window convention for breaking changes. + +**What was accepted before.** `find`, `findOne`, `count`, `aggregate`, `insert` (and `insertMany`), `update`, `delete` and `validate` resolved their target through the schema registry and, for a name the registry did not resolve, handed the name to the driver as a raw table name. A caller in the process (a sandboxed action or hook body's `ctx.api`, an action handler, host code) could therefore read or write a table by a name the generic data door refuses with `404 OBJECT_NOT_FOUND`, and every in-process guard keyed by a registered object name could be stepped around by naming the target another way. + +**What is refused now.** Such a name is refused with the data door's own envelope (`OBJECT_NOT_FOUND`, `status: 404`, the name on `object`, built by `objectNotFoundError` from `@objectstack/core`) before any hook, middleware or driver runs. A registered name resolves exactly as before. `judgeFilter` still judges the filter for a name the registry does not hold, because it reads nothing and reaches no driver; execution refuses that object before admission. + +**Inside the engine.** The single-tenant organization probe asks the registry first: an install that registers no organization object is the lean case it always was, with no organization to derive, and the write proceeds unstamped without a driver read. + +**The fix.** Register the object (in the stack, with `registry.registerObject`, or through a plugin manifest) before addressing it through the engine. Host code that must reach storage without a registry entry addresses the driver itself (`datasource(name)`, `getDriverForObject(name)`), a path a sandboxed body cannot reach. diff --git a/.changeset/21516-spec-judge-filter-docblock.md b/.changeset/21516-spec-judge-filter-docblock.md new file mode 100644 index 00000000000..305df89e9e4 --- /dev/null +++ b/.changeset/21516-spec-judge-filter-docblock.md @@ -0,0 +1,9 @@ +--- +'@objectstack/spec': patch +--- + +The `IObjectQLEngine.judgeFilter` docblock states that execution refuses an object the registry does not know before admission + +Clause-②: no + +The comment ships in the package's type declarations (`dist/*.d.ts`); `src/contracts/objectql-engine.ts` itself is not in `files[]`. It used to say that, for an object the registry does not know, the schema-free doors still judge "as at execution". Execution now refuses such an object before admission (`OBJECT_NOT_FOUND`, 404), so the comment says that answer is about the object, not the filter, and is not this member's verdict. ⛔ No schema, parse, export or accept-set change. diff --git a/packages/runtime/src/unresolved-object-name.actions-door.pin.test.ts b/packages/runtime/src/unresolved-object-name.actions-door.pin.test.ts new file mode 100644 index 00000000000..304fb40cbb6 --- /dev/null +++ b/packages/runtime/src/unresolved-object-name.actions-door.pin.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21516] One name space for the engine's in-process verbs and the data door, + * pinned at the door a deployment exposes: an action body run through REST + * `/actions`. + * + * A sandboxed body's object API reaches the engine's in-process verbs. Those + * verbs used to hand a name the schema registry does not resolve to the driver + * as a raw table name, so a body could read a table by a name the generic data + * door refuses with `404 OBJECT_NOT_FOUND` — and every in-process guard keyed by + * a registered object name could be stepped around by naming the target some + * other way. The engine now refuses that name with the door's own envelope. + * + * The target is a table that EXISTS and holds a row, created out of band at the + * driver and registered nowhere: the class the card measured, without naming + * any protected table. Pinned, per the triage ruling: + * - the measured exit now answers not-found, for a member and an + * administrator (the action runs elevated, so both reach the same engine); + * - a registered name read the same way is the control; + * - the data door's own answer for the same name is the reference. + * + * Composition: the plugin set, in order, `@objectstack/verify`'s `bootStack` + * uses (it mirrors `objectstack dev` / `serve`), as the #21454 reader-seam pins + * assemble it; the boot is paid in `beforeAll`, never inside a case. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { createRestApiPlugin } from '@objectstack/rest'; +import { AuthPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin, appSecurityPluginOptions } from '@objectstack/plugin-security'; +import { SharingServicePlugin } from '@objectstack/plugin-sharing'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { AppPlugin } from './app-plugin.js'; +import { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +const BOOT_TIMEOUT = 180_000; +const ORIGIN = 'http://localhost:3000'; +const API = '/api/v1'; +const ADMIN = { email: 'admin@objectos.ai', password: 'admin123' }; +const MEMBER = { email: 'pin-21516-member@example.invalid', password: 'Member-Pass-123' }; + +/** A table that exists at the driver and that no registry entry names. */ +const UNREGISTERED = 'pin_offbook_21516'; +/** The value the out-of-band row carries: what no answer below may contain. */ +const SENTINEL = 'offbook-sentinel-21516'; +/** The control row, written through the data door on the registered object. */ +const CONTROL_TITLE = 'registered-control-21516'; + +const body = (source: string) => ({ language: 'js', source, capabilities: ['api.read'], timeoutMs: 5000 }); + +const PIN_APP: any = { + manifest: { id: 'com.pin.unresolved21516', name: 'Unresolved name pins', version: '1.0.0' }, + objects: [ + { + name: 'pin_note', + label: 'Pin note', + fields: { title: { type: 'text', label: 'Title' } }, + actions: [ + { + name: 'body_reads_unregistered', + label: 'Body reads an unregistered name', + type: 'script', + body: body(`const rows = await ctx.api.object('${UNREGISTERED}').find({});\nreturn { rows };`), + }, + { + name: 'body_reads_registered', + label: 'Body reads a registered name', + type: 'script', + body: body(`const rows = await ctx.api.object('pin_note').find({});\nreturn { rows };`), + }, + ], + }, + ], + permissions: [ + { + name: 'pin_21516_member_default', + label: 'Pin member default', + isDefault: true, + objects: { pin_note: { allowRead: true, allowCreate: true } }, + }, + ], +}; + +let kernel: any; +let httpServer: any; +let app: any; +let engine: any; +let adminToken: string; +let memberToken: string; +let prevNodeEnv: string | undefined; + +const req = (path: string, init?: RequestInit) => app.request(`${ORIGIN}${API}${path}`, init); +const as = (token: string | undefined, method: string, path: string, payload?: unknown) => + req(path, { + method, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + ...(payload !== undefined ? { body: JSON.stringify(payload) } : {}), + }); + +async function readJson(res: Response): Promise { + const text = await res.text(); + try { + return JSON.parse(text); + } catch { + return text; + } +} + +/** The ADR-0112 code, wherever the door's envelope carries it. */ +const codeOf = (payload: any): unknown => payload?.error?.code ?? payload?.code; + +async function signIn(who: { email: string; password: string }): Promise { + const res = await req('/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(who), + }); + if (!res.ok) throw new Error(`pin signIn failed: ${res.status} ${await res.text()}`); + return (await res.json() as any).token; +} + +async function signUpMember(): Promise { + // Default audience posture is invite_only: enter through a pending invitation, + // the lane `@objectstack/verify`'s signUp takes. + await engine.insert( + 'sys_invitation', + { + id: 'inv_pin_21516', + email: MEMBER.email, + status: 'pending', + organization_id: 'org_pin_21516', + role: 'member', + inviter_id: 'usr_pin_21516', + expires_at: new Date(Date.now() + 3_600_000), + }, + { context: { isSystem: true } }, + ); + const res = await req('/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: MEMBER.email, password: MEMBER.password, name: 'pin member' }), + }); + if (!res.ok) throw new Error(`pin signUp failed: ${res.status} ${await res.text()}`); + return (await res.json() as any).token; +} + +beforeAll(async () => { + prevNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; // the dev-admin seed, as `objectstack dev` / bootStack arm it + + kernel = new ObjectKernel(); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } })); + await kernel.use(new HonoServerPlugin({ port: 0 })); + await kernel.use(new AppPlugin(PIN_APP)); + await kernel.use(new PlatformObjectsPlugin()); + await kernel.use(new AuthPlugin({ secret: 'unresolved-name-21516-secret', autoDefaultOrganization: false })); + await kernel.use(new SecurityPlugin(appSecurityPluginOptions(PIN_APP))); + await kernel.use(new SharingServicePlugin()); + await kernel.use(createRestApiPlugin({})); + await kernel.use(createDispatcherPlugin({})); + await kernel.bootstrap(); + + httpServer = await kernel.getServiceAsync('http-server'); + app = httpServer.getRawApp(); + engine = await kernel.getServiceAsync('objectql'); + + // The out-of-band table, created and written at the DRIVER (host code's + // declared internal path), and registered nowhere. + const driver = engine.getDriverByName(engine.getDefaultDriverName()); + await driver.syncSchema(UNREGISTERED, { + name: UNREGISTERED, + fields: { id: { name: 'id', type: 'text', primaryKey: true }, secret: { name: 'secret', type: 'text' } }, + }); + await driver.create(UNREGISTERED, { id: 'offbook_1', secret: SENTINEL }); + + adminToken = await signIn(ADMIN); + memberToken = await signUpMember(); + const seeded = await as(adminToken, 'POST', '/data/pin_note', { title: CONTROL_TITLE }); + if (seeded.status >= 300) throw new Error(`pin seed refused: ${seeded.status} ${JSON.stringify(await readJson(seeded))}`); +}, BOOT_TIMEOUT); + +afterAll(async () => { + try { await httpServer?.close?.(); } catch { /* best-effort */ } + try { await kernel?.shutdown?.(); } catch { /* best-effort */ } + if (prevNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = prevNodeEnv; +}, 60_000); + +describe('[#21516] precondition and reference', () => { + it('the table exists at the driver and holds the row; no registry entry names it', async () => { + expect(engine.registry.getObject(UNREGISTERED)).toBeUndefined(); + const driver = engine.getDriverByName(engine.getDefaultDriverName()); + const rows: any[] = await driver.find(UNREGISTERED, {}); + expect(rows.map((r) => r.secret)).toEqual([SENTINEL]); + }); + + it('the generic data door answers 404 OBJECT_NOT_FOUND for the same name', async () => { + const res = await as(adminToken, 'GET', `/data/${UNREGISTERED}`); + const payload = await readJson(res); + expect(res.status).toBe(404); + expect(codeOf(payload)).toBe('OBJECT_NOT_FOUND'); + expect(JSON.stringify(payload)).not.toContain(SENTINEL); + }); +}); + +describe('[#21516] an action body via /actions reading a name the registry does not resolve', () => { + for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) { + it(`invoked by the ${role}: answers not-found, and nothing of the table reaches the answer`, async () => { + const res = await as(token(), 'POST', '/actions/pin_note/body_reads_unregistered', { params: {} }); + const payload = await readJson(res); + expect({ status: res.status, code: codeOf(payload) }).toEqual({ status: 404, code: 'OBJECT_NOT_FOUND' }); + expect(JSON.stringify(payload)).not.toContain(SENTINEL); + }); + } +}); + +describe('[#21516] CONTROL — the same body shape on a registered name', () => { + for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) { + it(`invoked by the ${role}: served`, async () => { + const res = await as(token(), 'POST', '/actions/pin_note/body_reads_registered', { params: {} }); + const payload = await readJson(res); + expect(res.status).toBe(200); + expect(JSON.stringify(payload)).toContain(CONTROL_TITLE); + }); + } +}); From ffc0e5b5456ecfbca622d25e27316369aea53d91 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:20:00 +0000 Subject: [PATCH 14/15] fix(cli): os migrate account-issuer reads sys_account through its driver, not the engine The read-only boot composes no auth plugin, so sys_account is not a registered object there, and the engine now refuses a name its registry does not resolve before any driver is asked. The pre-flight inventories the physical table in its legacy shape, so it reads through the driver the engine routes that name to; the missing-table refusal of that read is still the only one recognised as no rows. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .../src/commands/migrate/account-issuer.ts | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/migrate/account-issuer.ts b/packages/cli/src/commands/migrate/account-issuer.ts index 7c4227e10a4..1647c67f0e9 100644 --- a/packages/cli/src/commands/migrate/account-issuer.ts +++ b/packages/cli/src/commands/migrate/account-issuer.ts @@ -17,6 +17,14 @@ import { } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; +/** The table this pre-flight inventories. Never written. */ +const SYS_ACCOUNT = 'sys_account'; + +/** The driver read this pre-flight issues: `find` only, read-only by construction. */ +interface AccountTableDriver { + find(object: string, query: Record): Promise; +} + /** * `os migrate account-issuer` — the PLAN leg of the `sys_account.issuer` * retirement (#17440). @@ -126,26 +134,45 @@ export default class MigrateAccountIssuer extends Command { const engine = (stack.kernel as { getService?: (n: string) => unknown }).getService?.call( stack.kernel, 'objectql', - ); + ) as { getDriverForObject?: (object: string) => AccountTableDriver | undefined } | undefined; if (!flags.json) printStep('Scanning sys_account…'); + // The pre-flight reads the PHYSICAL `sys_account` table through the + // driver the engine routes that name to, not through the engine's + // `find`. This boot composes no `AuthPlugin`, so `sys_account` is not a + // registered object here, and the engine refuses a name its registry + // does not resolve (`OBJECT_NOT_FOUND`) before any driver is asked. That + // refusal is a fact about this boot's composition, never about the + // database: ⛔ reading it as "no rows" would report a table full of + // accounts as a clean pre-flight and authorise the drop. The table is + // read in its legacy shape, `issuer` included, which is the very column + // the registered schema no longer declares, so the driver (the path the + // engine leaves to host code) is the reader this inventory needs. + const driver = engine?.getDriverForObject?.(SYS_ACCOUNT); + if (!driver || typeof driver.find !== 'function') { + throw new Error( + `No driver serves ${SYS_ACCOUNT} on this stack, so the table cannot be enumerated. ` + + 'Refusing rather than reporting an unread table as clean.', + ); + } + // [#21552] A database with no `sys_account` table holds no account, so no // two rows collide: the probe reads it as no rows. The read is not - // avoided, measured: this boot composes no `AuthPlugin`, so `sys_account` - // is not a registered object and the held-back sync never lists it, and + // avoided, measured: `sys_account` is not a registered object on this + // boot, so the held-back sync never lists it, and // `stack.tableAbsent('sys_account')` answers false on every database. - // The refusal is therefore recognised, with the shared predicate and for - // this command's own table only. ⛔ No other refused read is softened: it - // still throws the probe's refusal below and is never read as clean. + // The driver's missing-table refusal is therefore recognised, with the + // shared predicate and for this command's own table only. ⛔ No other + // refused read is softened: it still throws the probe's refusal below + // and is never read as clean. let noAccountTable = false; - const readEngine = engine as Parameters[0]; - const readView: typeof readEngine = { - find: async (object, query, options) => { + const readView: Parameters[0] = { + find: async (object, query) => { try { - return await readEngine.find(object, query, options); + return await driver.find(object, query); } catch (error) { - if (object !== 'sys_account' || !isMissingTableError(error, object)) throw error; + if (object !== SYS_ACCOUNT || !isMissingTableError(error, object)) throw error; noAccountTable = true; return []; } From 2df18ea792202e43ae85a0d3a7d4a5ff683af9b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:23:31 +0000 Subject: [PATCH 15/15] fix(cli): account-issuer resolves its driver at the read, so a missing driver is the probe's refusal Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .../cli/src/commands/migrate/account-issuer.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/migrate/account-issuer.ts b/packages/cli/src/commands/migrate/account-issuer.ts index 1647c67f0e9..bfc270d279f 100644 --- a/packages/cli/src/commands/migrate/account-issuer.ts +++ b/packages/cli/src/commands/migrate/account-issuer.ts @@ -149,14 +149,7 @@ export default class MigrateAccountIssuer extends Command { // read in its legacy shape, `issuer` included, which is the very column // the registered schema no longer declares, so the driver (the path the // engine leaves to host code) is the reader this inventory needs. - const driver = engine?.getDriverForObject?.(SYS_ACCOUNT); - if (!driver || typeof driver.find !== 'function') { - throw new Error( - `No driver serves ${SYS_ACCOUNT} on this stack, so the table cannot be enumerated. ` + - 'Refusing rather than reporting an unread table as clean.', - ); - } - + // // [#21552] A database with no `sys_account` table holds no account, so no // two rows collide: the probe reads it as no rows. The read is not // avoided, measured: `sys_account` is not a registered object on this @@ -169,6 +162,12 @@ export default class MigrateAccountIssuer extends Command { let noAccountTable = false; const readView: Parameters[0] = { find: async (object, query) => { + // No driver is not an empty table: the probe turns this into its + // refusal, never into a clean report. + const driver = engine?.getDriverForObject?.(object); + if (!driver || typeof driver.find !== 'function') { + throw new Error(`no driver serves ${object} on this stack`); + } try { return await driver.find(object, query); } catch (error) {