Skip to content

Commit 62ca3de

Browse files
hotlongclaude
andcommitted
fix(runtime): resolve a service on the request's own kernel first on a kernel-resolver host
On the hosted multi-tenant runtime the host kernel holds singleton protocol / metadata / objectql, and a singleton answers getServiceAsync(name, scopeId) whatever the scope. resolveService asked that host lookup first, so every dispatcher door passing context.environmentId read the host's data plane: POST /actions/:object/:action loaded its subject row through a protocol that registers none of the environment's objects (OBJECT_NOT_FOUND, swallowed by loadActionSubjectRecord) and answered 404 RECORD_NOT_FOUND for a row the caller can read. The REST data routes never had this, because RestServer.resolveProtocol takes the environment kernel's own protocol. A request whose kernel is not the default kernel now resolves on that kernel first and falls back to the host's scoped lookup. A host with no request kernel of its own resolves exactly as before. resolveServiceOrLoud (the identity step's tenancy read) keeps its order on purpose. Co-authored-by: Claude <noreply@anthropic.com>
1 parent e357a44 commit 62ca3de

3 files changed

Lines changed: 211 additions & 3 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): on a `kernel-resolver` host, a dispatcher door resolves a service on the request's own environment kernel before the host kernel. `HttpDispatcher.resolveService` used to ask the host kernel's scoped lookup first, and the host kernel's singleton `protocol` / `metadata` / `objectql` answered it whatever the scope. So `POST /api/v1/actions/:object/:action` loaded its subject row through a protocol that registers none of the environment's objects, and it answered `404 RECORD_NOT_FOUND` for a row the caller can read. Hosts with no request kernel of their own (single-environment, SharedProjectPlugin) resolve exactly as before, and an id that names nothing is still refused with 404.
6+
7+
Clause-②: no
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#22944] A request the `kernel-resolver` routed to its OWN kernel is answered
4+
// by that kernel first.
5+
//
6+
// ## The defect these pins hold down
7+
//
8+
// `HttpDispatcher.resolveService` asked `defaultKernel.getServiceAsync(name,
9+
// scopeId)` BEFORE the request's own kernel. On a `kernel-resolver` host (the
10+
// hosted multi-tenant runtime) the host kernel holds SINGLETON `protocol` /
11+
// `metadata` / `objectql`, and a singleton answers that call whatever the
12+
// scope — so every dispatcher door passing `context.environmentId` was handed
13+
// the HOST's data plane. Measured on a hosted-shape rig (objectstack-ai/hotcrm#2065):
14+
// `POST /actions/crm_lead/convert_lead` loaded its subject row through the
15+
// host's protocol, which registers none of the environment's objects
16+
// (`OBJECT_NOT_FOUND`); `loadActionSubjectRecord` swallowed that, and
17+
// `refuseDeniedSubjectLoad` answered `404 RECORD_NOT_FOUND` for a row the
18+
// caller reads with `GET /data/crm_lead/:id` in the same session.
19+
//
20+
// ## POPULATION
21+
//
22+
// Covers: the probe `resolveService` (via `domainDeps.resolveService`, the
23+
// member every domain reads), on REAL kernels, in the three host shapes that
24+
// reach it — a `kernel-resolver` host (request kernel ≠ default kernel), a
25+
// single-environment host (no request kernel), and SharedProjectPlugin (a
26+
// SCOPED factory on the default kernel, no request kernel) — plus the action
27+
// door's subject-record load driven through the real `callData`.
28+
//
29+
// Does NOT cover: `resolveServiceOrLoud` / `classifyService`, whose order this
30+
// card deliberately leaves alone (it decides which kernel's `tenancy` answers
31+
// the identity step — see `./domains/scoped-service-miss-attribution.test.ts`).
32+
33+
import { describe, it, expect } from 'vitest';
34+
35+
import { ObjectKernel, ServiceLifecycle } from '@objectstack/core';
36+
import { recordNotFoundError } from '@objectstack/metadata-protocol';
37+
38+
import { HttpDispatcher } from './http-dispatcher.js';
39+
import type { HttpProtocolContext } from './http-dispatcher.js';
40+
import { callData, loadActionSubjectRecord, refuseDeniedSubjectLoad } from './action-execution.js';
41+
42+
const ENV_ID = 'env-northwind';
43+
const OBJECT = 'crm_lead';
44+
const RECORD_ID = 'GTS2jcGfJc2tHdxh';
45+
46+
/** No graceful-shutdown handlers: one kernel per case would otherwise pile up process listeners. */
47+
const bareKernel = (): ObjectKernel =>
48+
new ObjectKernel({ gracefulShutdown: false, skipSystemValidation: true });
49+
50+
/**
51+
* The host kernel's protocol: it registers NONE of the environment's objects,
52+
* so reading one through it is the `OBJECT_NOT_FOUND` the rig measured.
53+
*/
54+
function hostProtocol() {
55+
return {
56+
__tag: 'host',
57+
async getData(req: { object: string }) {
58+
throw Object.assign(new Error(`Object '${req.object}' not found`), { code: 'OBJECT_NOT_FOUND', status: 404 });
59+
},
60+
};
61+
}
62+
63+
/** The environment kernel's protocol: it serves the environment's row, and refuses an id that names nothing. */
64+
function envProtocol() {
65+
return {
66+
__tag: 'env',
67+
async getData(req: { object: string; id: string }) {
68+
if (req.object === OBJECT && req.id === RECORD_ID) {
69+
return { object: OBJECT, id: RECORD_ID, record: { id: RECORD_ID, company: 'Northwind Energy' } };
70+
}
71+
throw recordNotFoundError(req.object, req.id);
72+
},
73+
};
74+
}
75+
76+
type DomainDeps = {
77+
resolveService(context: HttpProtocolContext, name: string, environmentId?: string): Promise<any>;
78+
};
79+
/** `domainDeps` is private; this package's dispatcher pins reach it the same way. */
80+
const depsOf = (d: HttpDispatcher): DomainDeps & Record<string, any> => (d as any).domainDeps;
81+
82+
/** The kernel-resolver shape: a host kernel and the environment kernel a request was routed to. */
83+
function kernelResolverHost() {
84+
const host = bareKernel();
85+
host.registerService('protocol', hostProtocol());
86+
host.registerService('host-only', { __tag: 'host-only' });
87+
const env = bareKernel();
88+
env.registerService('protocol', envProtocol());
89+
const dispatcher = new HttpDispatcher(host as never);
90+
const context = { kernel: env, environmentId: ENV_ID } as unknown as HttpProtocolContext;
91+
return { dispatcher, context };
92+
}
93+
94+
describe('[#22944] a request routed to its own kernel is answered by that kernel first', () => {
95+
it('resolves `protocol` on the request\'s environment kernel, not the host kernel\'s singleton', async () => {
96+
const { dispatcher, context } = kernelResolverHost();
97+
const protocol = await depsOf(dispatcher).resolveService(context, 'protocol', ENV_ID);
98+
expect(protocol.__tag).toBe('env');
99+
});
100+
101+
it('still falls back to the host kernel for a service the environment kernel does not have', async () => {
102+
const { dispatcher, context } = kernelResolverHost();
103+
const svc = await depsOf(dispatcher).resolveService(context, 'host-only', ENV_ID);
104+
expect(svc?.__tag).toBe('host-only');
105+
});
106+
107+
it('the action door\'s subject-record load delivers a row the caller can read (was 404 RECORD_NOT_FOUND)', async () => {
108+
const { dispatcher, context } = kernelResolverHost();
109+
const deps = depsOf(dispatcher);
110+
const subject = await loadActionSubjectRecord(OBJECT, RECORD_ID, () =>
111+
callData(deps as never, context, 'get', { object: OBJECT, id: RECORD_ID }, undefined, ENV_ID, { userId: 'u1' } as never));
112+
113+
expect(subject.recordLoadDenied).toBe(false);
114+
expect(subject.record).toEqual({ id: RECORD_ID, company: 'Northwind Energy' });
115+
expect(() => refuseDeniedSubjectLoad(OBJECT, RECORD_ID, subject)).not.toThrow();
116+
});
117+
118+
it('an id that names nothing is still refused with 404 RECORD_NOT_FOUND (#16854 unchanged)', async () => {
119+
const { dispatcher, context } = kernelResolverHost();
120+
const deps = depsOf(dispatcher);
121+
const subject = await loadActionSubjectRecord(OBJECT, 'doesNotExist0000', () =>
122+
callData(deps as never, context, 'get', { object: OBJECT, id: 'doesNotExist0000' }, undefined, ENV_ID, { userId: 'u1' } as never));
123+
124+
expect(subject.recordLoadDenied).toBe(true);
125+
let thrown: any;
126+
try { refuseDeniedSubjectLoad(OBJECT, 'doesNotExist0000', subject); } catch (err) { thrown = err; }
127+
expect(thrown?.code).toBe('RECORD_NOT_FOUND');
128+
expect(thrown?.status ?? thrown?.statusCode).toBe(404);
129+
});
130+
});
131+
132+
describe('[#22944] hosts without a request kernel resolve exactly as before', () => {
133+
it('single-environment host: the default kernel answers', async () => {
134+
const host = bareKernel();
135+
host.registerService('protocol', envProtocol());
136+
const dispatcher = new HttpDispatcher(host as never);
137+
const context = {} as HttpProtocolContext;
138+
139+
const protocol = await depsOf(dispatcher).resolveService(context, 'protocol');
140+
expect(protocol.__tag).toBe('env');
141+
});
142+
143+
it('SharedProjectPlugin: the default kernel\'s SCOPED factory answers for the scope it is given', async () => {
144+
const host = bareKernel();
145+
const scopesAsked: (string | undefined)[] = [];
146+
host.registerServiceFactory(
147+
'protocol',
148+
(_ctx: unknown, scopeId?: string) => {
149+
scopesAsked.push(scopeId);
150+
return { __tag: `scoped:${scopeId}` };
151+
},
152+
ServiceLifecycle.SCOPED,
153+
);
154+
const dispatcher = new HttpDispatcher(host as never);
155+
const context = { environmentId: ENV_ID } as unknown as HttpProtocolContext;
156+
157+
const protocol = await depsOf(dispatcher).resolveService(context, 'protocol', ENV_ID);
158+
expect(protocol.__tag).toBe(`scoped:${ENV_ID}`);
159+
expect(scopesAsked).toContain(ENV_ID);
160+
});
161+
162+
it('a request whose kernel IS the default kernel keeps the scoped-first order', async () => {
163+
const host = bareKernel();
164+
host.registerServiceFactory(
165+
'protocol',
166+
(_ctx: unknown, scopeId?: string) => ({ __tag: `scoped:${scopeId}` }),
167+
ServiceLifecycle.SCOPED,
168+
);
169+
const dispatcher = new HttpDispatcher(host as never);
170+
const context = { kernel: host, environmentId: ENV_ID } as unknown as HttpProtocolContext;
171+
172+
const protocol = await depsOf(dispatcher).resolveService(context, 'protocol', ENV_ID);
173+
expect(protocol.__tag).toBe(`scoped:${ENV_ID}`);
174+
});
175+
});

‎packages/runtime/src/http-dispatcher.ts‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2382,8 +2382,30 @@ export class HttpDispatcher {
23822382
* When `scopeId` is provided, tries the SCOPED factory on `defaultKernel` first (SharedProjectPlugin
23832383
* mode). Falls back to `kernel` — THE REQUEST'S OWN kernel, passed in by the
23842384
* caller (#5155) — for singleton / legacy services.
2385+
*
2386+
* [#22944] Except when the request carries a kernel of its OWN — a
2387+
* `kernel-resolver` host routed it to its environment's kernel, so
2388+
* `kernel !== defaultKernel`. That kernel answers first. The host kernel's
2389+
* SINGLETONS (`protocol`, `metadata`, `objectql`) answer
2390+
* `getServiceAsync(name, scopeId)` whatever the scope, so asking the host
2391+
* first handed every dispatcher door that passes `context.environmentId`
2392+
* the HOST's data plane: `POST /actions/:object/:action` loaded its subject
2393+
* row from a protocol that registers none of the environment's objects
2394+
* (`OBJECT_NOT_FOUND`, swallowed by `loadActionSubjectRecord`), and
2395+
* answered `404 RECORD_NOT_FOUND` for a row the caller can read. The
2396+
* REST-owned routes never had this — `RestServer.resolveProtocol` takes the
2397+
* environment kernel's own `protocol`. A request with no kernel of its own
2398+
* (single-environment hosts, SharedProjectPlugin) resolves exactly as before.
23852399
*/
23862400
private async resolveService(kernel: any, name: string, scopeId?: string) {
2401+
if (kernel && kernel !== this.defaultKernel && typeof kernel.getServiceAsync === 'function') {
2402+
try {
2403+
const svc = await kernel.getServiceAsync(name, scopeId);
2404+
if (svc != null) return svc;
2405+
} catch {
2406+
// Not on the request's own kernel — fall through to the host's scoped lookup
2407+
}
2408+
}
23872409
// Prefer scoped lookup on defaultKernel when scopeId is given (shared-kernel / multi-environment mode)
23882410
if (scopeId && typeof this.defaultKernel.getServiceAsync === 'function') {
23892411
try {
@@ -2443,9 +2465,13 @@ export class HttpDispatcher {
24432465
* re-raised unbranded, for the resolver to answer as
24442466
* `AuthzStoreUnavailableError` (503).
24452467
*
2446-
* The chain order is `resolveService`'s (scoped lookup on the host kernel
2447-
* first, then the request's own kernel), so WHICH registry answers is
2448-
* unchanged; only what a rejection MEANS is. A host with no async accessor
2468+
* The chain order is scoped lookup on the host kernel first, then the
2469+
* request's own kernel — `resolveService`'s order before #22944 — so WHICH
2470+
* registry answers is unchanged; only what a rejection MEANS is. ⚠️ #22944
2471+
* moved the probe's order on a `kernel-resolver` host (the request's own
2472+
* kernel first) and deliberately NOT this one: this lookup decides which
2473+
* kernel's `tenancy` answers the identity step, which is a separate
2474+
* question from where a data-plane row is read. A host with no async accessor
24492475
* (`KernelBase`-shaped, e.g. `LiteKernel`) supports no service factories,
24502476
* so "not registered" is the only fault it can report — it keeps the quiet
24512477
* probe, which is the same classification rather than a second collapse.

0 commit comments

Comments
 (0)