Skip to content

Commit eb9ef79

Browse files
fix(objectql): an in-process engine verb refuses an object name the registry does not resolve (#21516) (#21545)
Fixes #21516 Clause-②: yes (narrowing) An in-process engine verb now refuses an object name the schema registry does not resolve with the data door's own `OBJECT_NOT_FOUND` (404), instead of handing that name to the driver as a raw table name. One name space for the in-process verbs and the generic data door (triage ruling). The engine's accept set narrows; no surface is widened. The `yes` half of the clause line is the one new export, `objectNotFoundError`, in `@objectstack/core`. ## What changed - **`packages/core` (new `objectNotFoundError`).** One factory for the `OBJECT_NOT_FOUND` / 404 envelope, beside `recordNotFoundError` and for the same ADR-0076 D2 reason (the engine closure cannot import the package where the door's envelope was written). Both doors now build the refusal here. - **`packages/metadata-protocol` (the door).** `assertObjectRegistered` raises that shared factory: same wire status, same code. - **`packages/objectql` (the engine).** `resolveObjectName` throws `objectNotFoundError` for a name the registry does not resolve, rather than returning it as a physical table name. Every in-process verb (`find`, `findOne`, `count`, `aggregate`, `insert`, `insertMany`, `update`, `delete`, `validate`) resolves through it, so all refuse uniformly: no spelling allow-list, no per-caller marker. `judgeFilter` keeps judging the filter for an unresolved name (it reads nothing and reaches no driver), as its contract states. - **Three platform-internal, constant-name best-effort probes** that read a known system object and were already fail-soft on a missing table now treat the engine's refusal (attributed to their own object) as the same "not provisioned in this composition" case. A body cannot reach these paths: `ObjectQL.probeInstallOrganizations` (registry-presence guard), `SeedLoaderService.resolveSoleOrganizationId` and `SysMetadataRepository`'s history counters (refusal recognised by `code` and `object`). - **`packages/spec`.** The `IObjectQLEngine.judgeFilter` docblock states that execution refuses an unknown object before admission (comment only; it ships in the built type declarations). ## Why (classes, doors, roles, codes only) An action body invoked through the actions door could name a protected member of the stored-metadata family by a spelling the registry does not resolve and receive its stored content, whether a member or an administrator invoked it. The in-process verb handed that name to the driver as a raw table name, and every name-keyed in-process guard (PR #21513's reader seam among them) was addressed by the registered name only. The generic data door answers `OBJECT_NOT_FOUND` for the same name. The engine now answers the same, so the two doors share one name space and no name-keyed guard can be stepped around by naming its target some other way. ## Census: does any legitimate platform reader rely on the raw-table fall-through? Instrumented the resolver's fall-through and ran the `objectql`, `metadata-protocol` and `runtime` suites, plus a full boot, seed and door drive of four example apps (crm, showcase, todo, multi-package). The instrument was reverted in the branch; the net diff carries none of it. | composition | fall-through reads | reader relies on it? | |:--|:--|:--| | 4 example apps booted, seeded, driven through the doors | **0** | no: every platform reader addresses a registered object | | unit/integration suites (partial registries + tolerant stub drivers) | many | only test harnesses, plus the three constant-name probes below | Every in-repo caller that passes a possibly-unresolved name, by function: | caller | object (constant) | pre-change | disposition | |:--|:--|:--|:--| | `ObjectQL.probeInstallOrganizations` | the org object | fail-soft on missing table | moved: registry-presence, empty answer | | `SeedLoaderService.resolveSoleOrganizationId` | the org object | fail-soft on missing table | moved: recognise the refusal as not-provisioned | | `SysMetadataRepository` history counters | the history object | fail-soft on missing table | moved: recognise the refusal as not-provisioned | | `ObjectQL.cascadeDeleteRelations` / `planCascadeAtomicity` / `referenceExists` | a relation ref | already `try/catch` | unchanged: already tolerate a throw | | the data door's existence gate | the requested name | raised the 404 itself | now raises the shared factory | Conclusion: **no production or example reader relies on the fall-through.** ## Merge-queue fix `os migrate account-issuer` reads `sys_account` through the driver the engine routes that name to. Its read-only boot registers no `sys_account`, and the engine now refuses an unregistered name. The refusal is not read as absence, because that would report a table full of accounts as a clean pre-flight. #21570's missing-table reading for `sys_account` is kept, and every other failure still throws. ## Fixture triage (the test-only fallout, per the seat's answer) Every test that encoded the raw-table fall-through, by disposition. No ADR text is edited, and no pin ruled under #7929 (the cross-field withholding decision) changes what it asserts. The table-keyed `captureExpectedReadRefusals` noise pins keep their subject and reshape their reading (item 3). 1. **The unregistered name is the deliberate probe: the test now asserts the refusal** (`code` + `status`, and where the test watched the driver, that the driver saw nothing). - `objectql`: `engine-20822-no-field-map-type-blind-lowering`, `query-expression-conformance`, `engine.test`, `engine-undeclared-update-field`, `engine-undeclared-field-preflight`, `engine-temporal-comparand-door`, `engine-aggregate-filter` / `-having` / `-reference-verdict`, `engine-summary-recompute-context`, `registry-field-type-refused-at-door`, the `global-search-*` pins, `engine-judge-filter`, `engine-organization-probe-outage`. - `protocol-unregistered-object.test.ts`, case B of the card 3770 gate: the door's 404 assertion is unchanged; the engine assertion turns from "serves the row" to "refuses `OBJECT_NOT_FOUND` / 404"; header item ② gains one sentence naming #21516. - dogfood `registry-gate-wiring`: the premise reads ground truth at the driver (host code's declared internal path), then asserts the engine refuses the same name. 2. **The fall-through was incidental: the harness now registers what the platform reader resolves** (registered after boot or DDL, so nothing new is provisioned and every outage/absence subject keeps its meaning). - `objectql` metadata-write harnesses (delete, save, publish-meta, publish-package-drafts, protocol-derived-provenance, protocol-save-meta-repo-path, protocol-picklist, protocol-publish-canonical-fold): the stored-metadata family. - `rest` (14 harness files): the stored-metadata family, after DDL. - `plugin-security` (4 files) and the `http-conformance` stack: the authz resolver's read set, unprovisioned, so the missing-table answer is still what they measure. - `plugin-approvals` status-mirror cascade: the delegation object and the org object, unprovisioned. `service-settings`: the secret and setting-audit objects. 3. **Noise pins: same subject, reshaped reading.** The org probe for an unregistered org object is now refused before any driver, so a pin that read "the probe reached the driver and was withheld" now reads "the probe reached no driver" (`tablesSeen()` equal to empty where `silentChannels()` was read). `runtime` (about 17 files) and `trigger-record-change`. `expected-read-refusal-noise.channel-asymmetry.test.ts` registers its probe object, unprovisioned, so the real driver refusal is still what its two channels measure. 4. **`cli` served-boot control** (`schema-migrate.host-composition`): each hook's probe read is witnessed by its recorded `OBJECT_NOT_FOUND` answer, not by a driver line; the SQL driver suppresses that line for its own deferred set, so the line never was the subject. 5. `engine.test.ts`: one mock parameter typed (`name: string`), the `@objectstack/objectql#typecheck` red of the earlier heads. ## New pin: the measured public door `packages/runtime/src/unresolved-object-name.actions-door.pin.test.ts` boots the plugin set `bootStack` uses and drives REST `/actions`. The target is a table that exists and holds a sentinel row, created out of band at the driver and registered nowhere (the class the card measured, naming no protected table). For an administrator and a member, the action body's read answers `404 OBJECT_NOT_FOUND` and the sentinel appears nowhere in the answer. Control: the same body shape on a registered name is served. Reference: the generic data door's answer for the same name is the same 404. PR #21513's reader-seam pins stay green. ## Ablation (one-shot; nothing left in the tree) Mutation leg, `scripts/ablation-replace.mjs` on `engine.ts`: the refusal in `resolveObjectName` replaced by the old raw-table return plus a marker branch (marker on disk 1, refusal on disk 0); rebuilt; `ablation-dist-preflight`: marker present in 4 built files. Pins under mutation: - objectql (`protocol-unregistered-object`, `engine-20822-...`, `query-expression-conformance`, `engine-judge-filter`): `5 failed | 245 passed (250)` - runtime actions-door pin: `2 failed | 4 passed (6)` (both role cases) Restore leg: `git checkout HEAD -- engine.ts`; blob equals the HEAD blob `f5793bff919d`, `git diff HEAD` empty, porcelain clean; rebuilt; marker absent from all 14 built files. Pins restored: `250 passed (250)`, `6 passed (6)`. `engine.ts` is byte-identical on the final head (the later merge of `main` carried no `objectql` source). ## Verification on the final head `6752a29827` (merge of `origin/main` at `1ca1eb0972`) - `objectql` full suite: `367 files, 7384 passed`. - `metadata-protocol` full suite: `206 passed, 3 skipped files; 3187 passed, 19 skipped`. - `runtime` full suite: `317 files; 5176 passed, 19 skipped`. - `service-analytics` full suite: `175 files; 4152 passed, 253 skipped`. - `cli` unit tier: `252 files, 3685 passed`; integration tier, the three files this branch or the merged `main` touched: `36 passed`. - `mcp`: `35 files, 389 passed`; dogfood (`registry-gate-wiring` + the two files `main` added): `16 passed`; the `main`-added example, `metadata` and `core` files: green. - `plugin-security`, `plugin-approvals`, `service-settings`, `http-conformance`, `trigger-record-change` test tasks: turbo `38/38` (5 test tasks run, 33 cached builds). - Build closure for the above: `63/63` turbo tasks. - Before the merge (head `bab0903840`): typecheck of every touched package `76/76` tasks; `rest` repo project `177 passed`; the 14 `rest` harness files `552 passed, 21 skipped`. - CI on `6752a29827`: every check green except "Part-of PR must not also close its card", which read the earlier body; this body is its input. ## Acceptance notes - Recorded decision of card 3770 ("the engine deliberately does not reject; internal callers unaffected") is narrowed at the engine: the door's gate is unchanged and the engine now gives the same answer. ADR-0053's type-blind lowering is kept for a registered object with no field map, and removed for an unregistered name (the bypass itself). No ADR text is edited here. - Changesets: `@objectstack/core` minor (`Clause-②: yes`, the new export); `@objectstack/objectql` minor (`Clause-②: no (narrowing)`, with its ADR-0087 disposition); `@objectstack/metadata-protocol` patch; `@objectstack/spec` patch (docblock). - `packages/cli/test/refusal-renders-once.e2e.test.ts` (added on `main`) sits in neither of the cli package's two vitest projects and was not run here; it drives refusal rendering of `os init` / `os compile`, which this change does not reach. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 35dfb81 commit eb9ef79

81 files changed

Lines changed: 1102 additions & 223 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/core': minor
3+
---
4+
5+
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
6+
7+
Clause-②: yes
8+
9+
`@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.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
9+
- `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.
10+
- `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.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
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
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- 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). -->
10+
11+
**BREAKING** accept-set narrowing of the engine's in-process verbs, shipped as `minor` under the repo's launch-window convention for breaking changes.
12+
13+
**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.
14+
15+
**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.
16+
17+
**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.
18+
19+
**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.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The `IObjectQLEngine.judgeFilter` docblock states that execution refuses an object the registry does not know before admission
6+
7+
Clause-②: no
8+
9+
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.

‎packages/cli/src/commands/migrate/account-issuer.ts‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ import {
1717
} from '../../utils/format.js';
1818
import { bootSchemaStack } from '../../utils/schema-migrate.js';
1919

20+
/** The table this pre-flight inventories. Never written. */
21+
const SYS_ACCOUNT = 'sys_account';
22+
23+
/** The driver read this pre-flight issues: `find` only, read-only by construction. */
24+
interface AccountTableDriver {
25+
find(object: string, query: Record<string, unknown>): Promise<unknown>;
26+
}
27+
2028
/**
2129
* `os migrate account-issuer` — the PLAN leg of the `sys_account.issuer`
2230
* retirement (#17440).
@@ -126,26 +134,44 @@ export default class MigrateAccountIssuer extends Command {
126134
const engine = (stack.kernel as { getService?: (n: string) => unknown }).getService?.call(
127135
stack.kernel,
128136
'objectql',
129-
);
137+
) as { getDriverForObject?: (object: string) => AccountTableDriver | undefined } | undefined;
130138

131139
if (!flags.json) printStep('Scanning sys_account…');
132140

141+
// The pre-flight reads the PHYSICAL `sys_account` table through the
142+
// driver the engine routes that name to, not through the engine's
143+
// `find`. This boot composes no `AuthPlugin`, so `sys_account` is not a
144+
// registered object here, and the engine refuses a name its registry
145+
// does not resolve (`OBJECT_NOT_FOUND`) before any driver is asked. That
146+
// refusal is a fact about this boot's composition, never about the
147+
// database: ⛔ reading it as "no rows" would report a table full of
148+
// accounts as a clean pre-flight and authorise the drop. The table is
149+
// read in its legacy shape, `issuer` included, which is the very column
150+
// the registered schema no longer declares, so the driver (the path the
151+
// engine leaves to host code) is the reader this inventory needs.
152+
//
133153
// [#21552] A database with no `sys_account` table holds no account, so no
134154
// two rows collide: the probe reads it as no rows. The read is not
135-
// avoided, measured: this boot composes no `AuthPlugin`, so `sys_account`
136-
// is not a registered object and the held-back sync never lists it, and
155+
// avoided, measured: `sys_account` is not a registered object on this
156+
// boot, so the held-back sync never lists it, and
137157
// `stack.tableAbsent('sys_account')` answers false on every database.
138-
// The refusal is therefore recognised, with the shared predicate and for
139-
// this command's own table only. ⛔ No other refused read is softened: it
140-
// still throws the probe's refusal below and is never read as clean.
158+
// The driver's missing-table refusal is therefore recognised, with the
159+
// shared predicate and for this command's own table only. ⛔ No other
160+
// refused read is softened: it still throws the probe's refusal below
161+
// and is never read as clean.
141162
let noAccountTable = false;
142-
const readEngine = engine as Parameters<typeof probeAccountIdentityCollisions>[0];
143-
const readView: typeof readEngine = {
144-
find: async (object, query, options) => {
163+
const readView: Parameters<typeof probeAccountIdentityCollisions>[0] = {
164+
find: async (object, query) => {
165+
// No driver is not an empty table: the probe turns this into its
166+
// refusal, never into a clean report.
167+
const driver = engine?.getDriverForObject?.(object);
168+
if (!driver || typeof driver.find !== 'function') {
169+
throw new Error(`no driver serves ${object} on this stack`);
170+
}
145171
try {
146-
return await readEngine.find(object, query, options);
172+
return await driver.find(object, query);
147173
} catch (error) {
148-
if (object !== 'sys_account' || !isMissingTableError(error, object)) throw error;
174+
if (object !== SYS_ACCOUNT || !isMissingTableError(error, object)) throw error;
149175
noAccountTable = true;
150176
return [];
151177
}

‎packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -908,7 +908,8 @@ describe('a plan runs no app lifecycle hook (#21054)', () => {
908908
" ctx.hook('kernel:bootstrapped', async () => {",
909909
" appendFileSync(LOG, 'app|kernel:bootstrapped\\n');",
910910
` for (const object of ${JSON.stringify(PROBE_TABLES)}) {`,
911-
" try { await ctx.ql.find(object, { where: { name: 'x' }, limit: 1, context: SYS }); } catch { /* answered */ }",
911+
" try { await ctx.ql.find(object, { where: { name: 'x' }, limit: 1, context: SYS }); appendFileSync(LOG, `app|read|${object}|ok\\n`); }",
912+
" catch (e: any) { appendFileSync(LOG, `app|read|${object}|${e && e.code}\\n`); }",
912913
' }',
913914
' });',
914915
'};',
@@ -963,9 +964,15 @@ describe('a plan runs no app lifecycle hook (#21054)', () => {
963964
expect(log).toContain('app|onEnable');
964965
expect(log).toContain('app|kernel:bootstrapped');
965966
expect(log).toContain('host|kernel:bootstrapped');
967+
// [#21516] The engine now refuses a name its registry does not hold
968+
// before any driver, so a read of an object this boot never declared is
969+
// answered `OBJECT_NOT_FOUND` and no driver line is printed. The witness
970+
// that the hooks' reads were ISSUED — what the plan legs below prove
971+
// absent — is therefore each read's recorded answer, not a driver line.
966972
for (const table of PROBE_TABLES) {
967-
expect(captured.lines.some((l) => l.includes(`'${table}'`))).toBe(true);
973+
expect(log).toContain(`app|read|${table}|OBJECT_NOT_FOUND`);
968974
}
975+
expect(captured.lines.filter((l) => PROBE_TABLES.some((t) => l.includes(`'${t}'`)))).toEqual([]);
969976
} finally {
970977
captured.restore();
971978
await stack.shutdown();

‎packages/core/src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@ export * from './utils/metadata-activation-store.js';
155155
// with. `@objectstack/metadata-protocol` re-exports it from its original home.
156156
export * from './utils/record-not-found.js';
157157

158+
// The one `OBJECT_NOT_FOUND` envelope: the data door's object-existence gate
159+
// and the engine's in-process verbs refuse a name the registry does not
160+
// resolve with it (one name space for both doors).
161+
export * from './utils/object-not-found.js';
162+
158163
// Export in-memory fallbacks for core-criticality services
159164
export * from './fallbacks/index.js';
160165

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The 404 an object name answers when the schema registry does not resolve it.
5+
*
6+
* One name space, two doors. The generic data door (the protocol's
7+
* object-existence gate) and the engine's in-process verbs (`find`, `findOne`,
8+
* `count`, `aggregate`, `insert`, `update`, `delete`, `validate`) resolve an
9+
* object name through the same registry, and a name the registry does not hold
10+
* is refused by both with this one envelope: `code: 'OBJECT_NOT_FOUND'`,
11+
* `status: 404`, and the name the caller asked for on `object`.
12+
*
13+
* Why the engine refuses too: an in-process verb used to hand an unresolved
14+
* name to the driver as a raw table name. Every guard keyed by a registered
15+
* object name then judged a name that named no object, while the driver read
16+
* whatever table the spelling reached. The door already refused that name, so
17+
* an in-process caller (a sandboxed body, an action handler, a hook) could
18+
* read what the door would not serve.
19+
*
20+
* Why it lives in `@objectstack/core`: ADR-0076 D2's boundary ratchet
21+
* (`core-boundary.ratchet.test.ts`) keeps `engine.ts` from importing
22+
* `@objectstack/metadata-protocol`, where the door's envelope was first
23+
* written. `recordNotFoundError` moved down here for the same reason, and both
24+
* doors call this factory rather than each spelling the envelope.
25+
*
26+
* The wire answer is the data-error classifier's (`mapDataError`,
27+
* `@objectstack/types`), which reads `code` and `object`; this message is the
28+
* operator-facing text and stays the door's original sentence.
29+
*/
30+
export function objectNotFoundError(object: string): Error {
31+
const err = new Error(`Object '${object}' not found`) as Error & {
32+
code?: string;
33+
status?: number;
34+
object?: string;
35+
};
36+
err.code = 'OBJECT_NOT_FOUND';
37+
err.status = 404;
38+
err.object = object;
39+
return err;
40+
}

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type {
44
DataProtocol, MetadataProtocol, PackageProtocol,
55
} from '@objectstack/spec/api';
6-
import { IDataEngine, engineCanRollBack, recordNotFoundError } from '@objectstack/core';
6+
import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError } from '@objectstack/core';
77
import { declaredUserMessage, readEnvWithDeprecation, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types';
88
// [#6285] ADR-0105 D1's authority on "does this deployment wall organizations?".
99
// `resolveMultiOrgEnabled()` is DEMOTED and its own doc comment says answering
@@ -10371,21 +10371,24 @@ export class ObjectStackProtocolImplementation implements
1037110371
*
1037210372
* The REST API-exposure gate (`enforceApiAccess`, ADR-0049 / #1889) skips
1037310373
* objects it cannot find in metadata, and justified that with "the data
10374-
* path will 404 anyway". It would not. `engine.find` resolves an
10375-
* UNREGISTERED name straight to a physical table name
10376-
* (`resolveObjectName` → `StorageNameMapping.resolveTableName({ name })`),
10377-
* so the request only 404'd as a *side effect* of the driver complaining
10378-
* about a missing table (which the REST layer recognises by matching the
10379-
* driver's error string) — and did not 404 at all when a table with that
10380-
* name happened to exist: out-of-band DDL, a registration that failed
10381-
* after `syncObjectSchema` had already run, a registration race. In that
10382-
* window the exposure gate was silently skipped and the rows were served.
10374+
* path will 404 anyway". It would not. `engine.find` then resolved an
10375+
* UNREGISTERED name straight to a physical table name, so the request
10376+
* only 404'd as a *side effect* of the driver complaining about a missing
10377+
* table (which the REST layer recognises by matching the driver's error
10378+
* string) — and did not 404 at all when a table with that name happened
10379+
* to exist: out-of-band DDL, a registration that failed after
10380+
* `syncObjectSchema` had already run, a registration race. In that window
10381+
* the exposure gate was silently skipped and the rows were served.
1038310382
*
1038410383
* The gate lives HERE, at the protocol ingress, for the same reason
10385-
* `enforceApiAccess` does: this is the external API boundary. Internal
10386-
* callers (hooks, flows, migrations, raw ObjectQL) talk to the engine
10387-
* directly and are deliberately unaffected — `apiEnabled` and this check
10388-
* both control automatic API exposure, not data access.
10384+
* `enforceApiAccess` does: this is the external API boundary, and it
10385+
* answers before the query is parsed. `apiEnabled` controls automatic API
10386+
* exposure, not data access, and internal callers (hooks, flows,
10387+
* migrations, raw ObjectQL) are unaffected by it. The object-existence
10388+
* half is no longer the door's alone: the engine's in-process verbs now
10389+
* refuse a name the registry does not resolve with this same envelope
10390+
* (`objectNotFoundError`, `@objectstack/core`), so an in-process caller
10391+
* cannot read a table by a name this gate refuses.
1038910392
*
1039010393
* ## Tiering — mirrors the #3545 decision recorded in `api-exposure.ts`
1039110394
*
@@ -10474,11 +10477,9 @@ export class ObjectStackProtocolImplementation implements
1047410477
}
1047510478
return;
1047610479
}
10477-
const err: any = new Error(`Object '${object}' not found`);
10478-
err.code = 'OBJECT_NOT_FOUND';
10479-
err.status = 404;
10480-
err.object = object;
10481-
throw err;
10480+
// The one envelope, shared with the engine's in-process verbs, which
10481+
// refuse an unresolved name the same way (`objectNotFoundError`).
10482+
throw objectNotFoundError(object);
1048210483
}
1048310484

1048410485
/**

‎packages/metadata-protocol/src/seed-loader.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1649,12 +1649,24 @@ export class SeedLoaderService implements ISeedLoaderService {
16491649
// makes below — never a hand-rolled message test, so one vocabulary of
16501650
// "benign driver error" serves every seam that needs one.
16511651
//
1652+
// [#21516] The same absence in a composition that registers no
1653+
// `sys_organization` object at all (a single-tenant/lean runtime): the
1654+
// engine's in-process verbs now refuse an unresolved name with
1655+
// `OBJECT_NOT_FOUND` before any driver is asked, rather than reaching a
1656+
// table by that raw name. It is the not-provisioned case the missing-table
1657+
// arm already covers — no organization object here, so "no sole
1658+
// organization" is the truth and the historical NULL is right. Attributed
1659+
// on the error's own `object`, like the predicate above: a refusal naming
1660+
// a different object is not evidence about `sys_organization`.
1661+
//
16521662
// Everything else (connection loss, a timeout, a permission denial, a
16531663
// driver fault) means organizations may well exist and simply were not
16541664
// seen. It propagates, envelope intact: the seed run fails loudly instead
16551665
// of writing a batch of rows nobody will be able to see. No new error code
16561666
// and no new result field — the caller receives the read's own failure.
1657-
if (!isMissingTableError(error, 'sys_organization')) throw error;
1667+
const refused = error as { code?: unknown; object?: unknown } | null | undefined;
1668+
const refusedThisObject = refused?.code === 'OBJECT_NOT_FOUND' && refused.object === 'sys_organization';
1669+
if (!isMissingTableError(error, 'sys_organization') && !refusedThisObject) throw error;
16581670
}
16591671
return undefined;
16601672
}

0 commit comments

Comments
 (0)