Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
89cfd2a
chore(objectql): temporary census instrument on the unresolved-name f…
claude Oct 3, 2026
915a3e1
chore(objectql): remove the temporary census instrument
claude Oct 3, 2026
5a49d6a
fix(objectql): an in-process verb refuses an object name the registry…
claude Oct 3, 2026
5e87611
fix(objectql,metadata-protocol): internal org/history probes treat th…
claude Oct 3, 2026
7a6593b
Merge remote-tracking branch 'origin/main' into claude/issue-21516-un…
claude Oct 3, 2026
7bf4605
test(objectql): metadata-write harnesses register the stored-metadata…
claude Oct 3, 2026
672a348
test(objectql): migrate the pins that encoded the raw-table fall-through
claude Oct 3, 2026
514d90f
test(runtime): reshape the expected-refusal noise pins to the engine'…
claude Oct 3, 2026
b1ebc0a
test(plugins,services): harnesses register the objects their platform…
claude Oct 3, 2026
a1c0885
test(rest): harnesses register the stored-metadata family the protoco…
claude Oct 3, 2026
61add5a
test(cli): the served-boot control witnesses its hooks' reads by thei…
claude Oct 3, 2026
ddcfe20
test(dogfood): the gate premise reads ground truth at the driver; the…
claude Oct 3, 2026
8f5595f
test: record-change and http-conformance fixtures follow the engine's…
claude Oct 3, 2026
bab0903
test(runtime): pin the actions-door exit for a name the registry does…
claude Oct 3, 2026
6752a29
Merge remote-tracking branch 'origin/main' into claude/issue-21516-un…
claude Oct 3, 2026
b5cec3d
Merge remote-tracking branch 'origin/main' into claude/issue-21516-un…
claude Oct 3, 2026
ffc0e5b
fix(cli): os migrate account-issuer reads sys_account through its dri…
claude Oct 3, 2026
2df18ea
fix(cli): account-issuer resolves its driver at the read, so a missin…
claude Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/21516-core-object-not-found-error.md
Original file line number Diff line number Diff line change
@@ -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 '<name>' 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.
10 changes: 10 additions & 0 deletions .changeset/21516-metadata-protocol-refusal-readers.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions .changeset/21516-objectql-unresolved-name-refusal.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing at the engine's name resolution: no authorable spec key, export or metadata shape is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What a caller meant by a name the registry does not hold is not decidable by a conversion entry. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered); and the change narrows what runtime verbs accept, not a runtime interface or a type surface alone (not runtime-interface-only / type-surface-only). -->

**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.
9 changes: 9 additions & 0 deletions .changeset/21516-spec-judge-filter-docblock.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 37 additions & 11 deletions packages/cli/src/commands/migrate/account-issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): Promise<unknown>;
}

/**
* `os migrate account-issuer` — the PLAN leg of the `sys_account.issuer`
* retirement (#17440).
Expand Down Expand Up @@ -126,26 +134,44 @@ 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.
//
// [#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<typeof probeAccountIdentityCollisions>[0];
const readView: typeof readEngine = {
find: async (object, query, options) => {
const readView: Parameters<typeof probeAccountIdentityCollisions>[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 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 [];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`); }",
' }',
' });',
'};',
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/utils/object-not-found.ts
Original file line number Diff line number Diff line change
@@ -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;
}
39 changes: 20 additions & 19 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -10371,21 +10371,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`
*
Expand Down Expand Up @@ -10474,11 +10477,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);
}

/**
Expand Down
14 changes: 13 additions & 1 deletion packages/metadata-protocol/src/seed-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
11 changes: 11 additions & 0 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading