Skip to content

Commit 582db16

Browse files
os-zhuangclaude
andcommitted
feat(adr-0006)!: Phase 5 — env resolution converges into the KernelResolver seam
BREAKING CHANGE (ADR-0006 Phase 5, final phase): the framework dispatcher no longer performs ANY multi-tenant environment resolution. The ~150-line `resolveEnvironmentContext` (URL → hostname → x-environment-id → session activeEnv → org default-env → single-env default, all via an environment registry) moved to the cloud distribution's resolver (@objectstack/objectos-runtime kernel-resolver.ts), behind the generic `KernelResolver` seam introduced in Phase 2. Dispatcher now: - contributes parsing hints only: `context.routePath` + unvalidated `context.urlEnvironmentId` (URL conventions stay framework-owned); - delegates env resolution + kernel selection to the host resolver, which SETS context.environmentId/dataDriver (membership enforcement, scope TTL touch and scoped service resolution key off them, unchanged); - serves single-environment hosts from defaultKernel with no env context. Removed from the public API: - HttpDispatcherOptions.kernelManager (+ the 'kernel-manager' service pickup for routing; the SERVICE remains a cloud-side concern), - the dispatcher's envRegistry constructor wiring (positional param kept, ignored, so 3-arg callers compile), - `EnvironmentDriverRegistry` / `KernelManager` type exports + runtime/src/cloud/environment-registry.ts (the last file of the cloud/ dir — directory gone; cloud owns its own copies), - @objectstack/hono createHonoApp envRegistry/kernelManager passthroughs (options kept as deprecated-ignored for source compat). handleData's 428 "environment not resolved" guard now keys off a registered kernel-resolver (was: env-registry service) — same multi-tenant signal, same behavior. The dispatcher's resolution test suite moved with the behavior — ported to cloud packages/objectos-runtime/src/kernel-resolver.test.ts (incl. the URL-param precedence / /cloud-skip / header-fallthrough cases). Validation: full-repo build sweep clean; runtime 368 + cli 240 + rest 100 + hono 67 tests green; cloud repo against this checkout: boot-smoke ALL GREEN, objectos-runtime 85 tests, typecheck clean (objectos / ee / cloud), live file-mode HTTP behavior identical (data 401 via requireAuth after hostname resolution through the cloud resolver). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d4fdae3 commit 582db16

7 files changed

Lines changed: 144 additions & 382 deletions

File tree

content/docs/references/ui/dashboard.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ Dashboard header action
108108
| **values** | `string[]` || Measure names — Y (at least one) |
109109
| **layout** | `Object` || Grid layout position |
110110
| **options** | `any` | optional | Widget specific configuration |
111+
| **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget |
111112
| **responsive** | `Object` | optional | Responsive layout configuration |
112113
| **aria** | `Object` | optional | ARIA accessibility attributes |
113114

packages/adapters/hono/src/index.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,14 @@ export interface ObjectStackHonoOptions {
6060
/** CORS configuration. Set to `false` to disable entirely. */
6161
cors?: ObjectStackHonoCorsOptions | false;
6262
/**
63-
* Optional {@link KernelManager}. When provided, the dispatcher will route
64-
* per-project requests to a project-scoped kernel resolved via
65-
* `kernelManager.getOrCreate(environmentId)`. When absent (self-hosted mode),
66-
* all requests use the single `kernel` passed above.
63+
* @deprecated RETIRED (ADR-0006 Phase 5) — ignored. Multi-tenant routing is
64+
* owned by the host's `KernelResolver` (registered as the
65+
* `kernel-resolver` kernel service); the dispatcher picks it up there.
6766
*/
6867
kernelManager?: KernelManager;
6968
/**
70-
* Optional {@link EnvironmentDriverRegistry}. When provided, the dispatcher
71-
* resolves incoming requests to a project via hostname / `X-Environment-Id`
72-
* header / session before invoking the KernelManager. Required for
73-
* host-based routing in cloud / multi-environment mode.
69+
* @deprecated RETIRED (ADR-0006 Phase 5) — ignored. Environment resolution
70+
* is owned by the host's `KernelResolver` (`kernel-resolver` service).
7471
*/
7572
envRegistry?: EnvironmentDriverRegistry;
7673
}
@@ -114,11 +111,11 @@ export function objectStackMiddleware(kernel: ObjectKernel) {
114111
export function createHonoApp(options: ObjectStackHonoOptions): Hono {
115112
const app = new Hono();
116113
const prefix = options.prefix || '/api';
117-
const dispatcher = new HttpDispatcher(
118-
options.kernel,
119-
options.envRegistry,
120-
options.kernelManager ? { kernelManager: options.kernelManager } : undefined,
121-
);
114+
// ADR-0006 Phase 5: env resolution + multi-kernel routing belong to the
115+
// host's KernelResolver (the dispatcher resolves the `kernel-resolver`
116+
// service itself). The legacy envRegistry/kernelManager options are
117+
// accepted-but-ignored for source compatibility.
118+
const dispatcher = new HttpDispatcher(options.kernel);
122119

123120
// ─── CORS Middleware ──────────────────────────────────────────────────────
124121
// Enabled by default. Controlled via options.cors or environment variables:

packages/runtime/src/cloud/environment-registry.ts

Lines changed: 0 additions & 53 deletions
This file was deleted.
Lines changed: 44 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* ADR-0006 Phase 2 — the generic `kernelResolver` seam.
4+
* ADR-0006 — the generic `kernelResolver` seam (Phase 5 semantics).
55
*
6-
* The dispatcher gains an optional host-injected resolver that owns
7-
* per-request kernel selection. It coexists with the legacy `kernelManager`
8-
* path and TAKES PRECEDENCE over it for primary routing. The framework ships
9-
* no multi-tenant resolver — these tests pin the seam contract (precedence +
10-
* undefined → defaultKernel fallback) that cloud's resolver plugs into.
6+
* The host's resolver owns per-request ENVIRONMENT RESOLUTION + kernel
7+
* selection: the dispatcher contributes parsing hints (`context.routePath`,
8+
* `context.urlEnvironmentId`), expects the resolver to set
9+
* `context.environmentId` / `context.dataDriver`, and serves from
10+
* `defaultKernel` when the resolver returns undefined or none is registered.
11+
* The framework ships no multi-tenant resolver — strategy coverage lives in
12+
* cloud `packages/objectos-runtime/src/kernel-resolver.test.ts`.
1113
*/
1214

1315
import { describe, it, expect, vi } from 'vitest';
1416
import { HttpDispatcher } from './http-dispatcher.js';
15-
import type { KernelResolver } from './http-dispatcher.js';
17+
import type { KernelResolver, HttpProtocolContext } from './http-dispatcher.js';
1618

1719
/** Minimal kernel whose objectql records which instance served the request. */
1820
function makeKernel(tag: string) {
@@ -32,27 +34,45 @@ function makeKernel(tag: string) {
3234
}
3335

3436
describe('HttpDispatcher — ADR-0006 kernelResolver seam', () => {
35-
it('prefers the injected kernelResolver over kernelManager for primary routing', async () => {
37+
it('delegates kernel selection to the resolver with parsing hints attached', async () => {
3638
const defaultKernel = makeKernel('default');
3739
const envKernel = makeKernel('env');
3840

39-
const resolveKernel = vi.fn(async () => envKernel);
40-
const kernelResolver: KernelResolver = { resolveKernel };
41-
const getOrCreate = vi.fn(async () => envKernel);
42-
41+
const resolveKernel = vi.fn(async (ctx: HttpProtocolContext) => {
42+
// Phase 5 contract: the resolver sets the environment context.
43+
ctx.environmentId = 'env-from-resolver';
44+
return envKernel;
45+
});
4346
const dispatcher = new HttpDispatcher(defaultKernel, undefined, {
44-
kernelResolver,
45-
kernelManager: { getOrCreate } as any,
47+
kernelResolver: { resolveKernel },
4648
enforceProjectMembership: false,
4749
});
4850

49-
await dispatcher.dispatch('GET', '/data/widget', undefined, {}, { request: {} } as any);
51+
const context: any = { request: { headers: { host: 'tenant.example.com' } } };
52+
await dispatcher.dispatch('GET', '/environments/env-123/data/widget', undefined, {}, context);
5053

51-
// The seam was consulted exactly once, with (context, defaultKernel)…
5254
expect(resolveKernel).toHaveBeenCalledTimes(1);
55+
const passed = resolveKernel.mock.calls[0][0] as HttpProtocolContext;
56+
// Dispatcher-provided hints (pure parsing, unvalidated):
57+
expect(passed.routePath).toBe('/environments/env-123/data/widget');
58+
expect(passed.urlEnvironmentId).toBe('env-123');
59+
// Resolver-set environment context survives for downstream stages:
60+
expect(context.environmentId).toBe('env-from-resolver');
5361
expect(resolveKernel.mock.calls[0][1]).toBe(defaultKernel);
54-
// …and it WON: the legacy kernelManager path was not used for routing.
55-
expect(getOrCreate).not.toHaveBeenCalled();
62+
});
63+
64+
it('does not parse /cloud/environments/:id as a scoped-path candidate', async () => {
65+
const defaultKernel = makeKernel('default');
66+
const resolveKernel = vi.fn(async () => undefined);
67+
const dispatcher = new HttpDispatcher(defaultKernel, undefined, {
68+
kernelResolver: { resolveKernel },
69+
enforceProjectMembership: false,
70+
});
71+
72+
const context: any = { request: {} };
73+
await dispatcher.dispatch('GET', '/cloud/environments/env-9', undefined, {}, context);
74+
const passed = resolveKernel.mock.calls[0][0] as HttpProtocolContext;
75+
expect(passed.urlEnvironmentId).toBeUndefined();
5676
});
5777

5878
it('falls back to defaultKernel when the resolver returns undefined', async () => {
@@ -64,28 +84,21 @@ describe('HttpDispatcher — ADR-0006 kernelResolver seam', () => {
6484
enforceProjectMembership: false,
6585
});
6686

67-
// Should not throw — undefined routes to the single defaultKernel.
6887
const result = await dispatcher.dispatch('GET', '/data/widget', undefined, {}, { request: {} } as any);
6988
expect(resolveKernel).toHaveBeenCalledTimes(1);
7089
expect(result).toBeDefined();
7190
});
7291

73-
it('uses the legacy kernelManager path when no resolver is injected (back-compat)', async () => {
92+
it('serves single-environment hosts (no resolver) from defaultKernel without env context', async () => {
7493
const defaultKernel = makeKernel('default');
75-
const envKernel = makeKernel('env');
76-
const getOrCreate = vi.fn(async () => envKernel);
77-
7894
const dispatcher = new HttpDispatcher(defaultKernel, undefined, {
79-
kernelManager: { getOrCreate } as any,
8095
enforceProjectMembership: false,
8196
});
8297

83-
// Force a resolved environmentId so the kernelManager branch is taken.
84-
await dispatcher.dispatch('GET', '/data/widget', undefined, {}, {
85-
request: {},
86-
environmentId: 'env_123',
87-
} as any);
88-
89-
expect(getOrCreate).toHaveBeenCalledWith('env_123');
98+
const context: any = { request: { headers: { host: 'localhost' } } };
99+
const result = await dispatcher.dispatch('GET', '/data/widget', undefined, {}, context);
100+
expect(result).toBeDefined();
101+
// No resolver registered → nothing resolves an environment.
102+
expect(context.environmentId).toBeUndefined();
90103
});
91104
});

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

Lines changed: 4 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1651,71 +1651,11 @@ describe('HttpDispatcher', () => {
16511651
});
16521652
});
16531653

1654-
// ------------------------------------------------------------------
1655-
// Phase 2 — URL-param project resolution
1656-
// ------------------------------------------------------------------
1657-
describe('resolveEnvironmentContext — URL-param environmentId', () => {
1658-
let envDispatcher: HttpDispatcher;
1659-
let envRegistry: any;
1654+
// NOTE (ADR-0006 Phase 5): the `resolveEnvironmentContext` suite that
1655+
// lived here moved with the behavior — environment resolution is owned
1656+
// by the host's KernelResolver now. Equivalent coverage:
1657+
// cloud packages/objectos-runtime/src/kernel-resolver.test.ts
16601658

1661-
beforeEach(() => {
1662-
envRegistry = {
1663-
resolveByHostname: vi.fn().mockResolvedValue(null),
1664-
resolveById: vi.fn(),
1665-
};
1666-
envDispatcher = new HttpDispatcher(kernel, envRegistry);
1667-
});
1668-
1669-
it('resolves environmentId from /projects/:id/... path before hostname / header', async () => {
1670-
envRegistry.resolveById = vi.fn().mockResolvedValue({ name: 'driver-for-proj-123' });
1671-
1672-
const context: any = { request: { headers: { host: 'anyhost' } } };
1673-
// Access the private resolver through a public entry point: handleData
1674-
// triggers resolveEnvironmentContext with the given path.
1675-
await (envDispatcher as any).resolveEnvironmentContext(
1676-
context,
1677-
'/api/v1/environments/proj-123/data/task',
1678-
);
1679-
1680-
expect(envRegistry.resolveById).toHaveBeenCalledWith('proj-123');
1681-
expect(context.environmentId).toBe('proj-123');
1682-
expect(context.dataDriver).toEqual({ name: 'driver-for-proj-123' });
1683-
// Hostname path should NOT have been tried.
1684-
expect(envRegistry.resolveByHostname).not.toHaveBeenCalled();
1685-
});
1686-
1687-
it('does not treat /cloud/environments/:id as a scoping prefix', async () => {
1688-
envRegistry.resolveById = vi.fn().mockResolvedValue({ name: 'wrong' });
1689-
1690-
const context: any = { request: { headers: {} } };
1691-
await (envDispatcher as any).resolveEnvironmentContext(
1692-
context,
1693-
'/api/v1/cloud/environments/proj-123',
1694-
);
1695-
1696-
// /cloud is explicitly skipped.
1697-
expect(envRegistry.resolveById).not.toHaveBeenCalled();
1698-
expect(context.environmentId).toBeUndefined();
1699-
});
1700-
1701-
it('falls through to header resolution when URL-param project is unknown', async () => {
1702-
envRegistry.resolveById = vi.fn()
1703-
.mockResolvedValueOnce(null) // URL-param lookup fails
1704-
.mockResolvedValueOnce({ name: 'header-driver' }); // header lookup succeeds
1705-
1706-
const context: any = {
1707-
request: { headers: { 'x-environment-id': 'proj-header' } },
1708-
};
1709-
await (envDispatcher as any).resolveEnvironmentContext(
1710-
context,
1711-
'/api/v1/environments/proj-unknown/data/task',
1712-
);
1713-
1714-
expect(envRegistry.resolveById).toHaveBeenNthCalledWith(1, 'proj-unknown');
1715-
expect(envRegistry.resolveById).toHaveBeenNthCalledWith(2, 'proj-header');
1716-
expect(context.environmentId).toBe('proj-header');
1717-
});
1718-
});
17191659

17201660
describe('enforceProjectMembership (RBAC)', () => {
17211661
const SYSTEM_ENVIRONMENT_ID = '00000000-0000-0000-0000-000000000001';

0 commit comments

Comments
 (0)