From e71b180a04a19ccb38f0fa51bb3cbc2398e78488 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 08:10:30 +0000 Subject: [PATCH] docs(runtime,metadata,rest): retire the expired "structurally unreachable until the E7 flip" comment family + cover the multi-tenant decline branch (#5399) The #5040 E7 publish flip landed (packages/spec/src/api/endpoint-publish-gate.ts opens with "This module is that flip"), and E8 moved endpoints back into the OpenAPI document. Thirteen comments across runtime/metadata/rest still asserted the pre-flip world -- "Structurally unreachable today", "Nothing calls this yet", "Today it emits nothing" -- which is exactly the #5078 defect: a comment that contradicts the code in front of it. Each site is rewritten to describe current reality and cite the authority (the publish gate, or the real-boot probe in packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts, whose /openapi.json case disproves "emits nothing" directly). Surrounding still-true prose is preserved. Pure comment change: the only non-comment line touched is a descriptive `note:` string in the route ledger. Also adds dispatcher-plugin.multi-tenant-endpoint.integration.test.ts, covering the E5b branch "multi-tenant resolution finds no environment -> decline + warn", which had zero test references repo-wide because the kernel-resolver PROVIDER ships in the cloud distribution. A stub resolver drives a real boot over a real socket: a placed request executes on its own tenant kernel; an unplaced one gets the transport's bare 404, the warn, and no probe of any declaration. The host kernel deliberately declares the same path too. Without that, the decline cases pass vacuously -- verified by deleting the branch and re-running: with tenant-only declarations only the warn assertion moved, because the step declined a second time for an unrelated reason. With the host copy in place, deleting the branch turns both decline cases red with "expected 200 to be 404", the cross-tenant answer the branch exists to prevent. Out of scope, filed as #5400: the sibling `else` branch still logs `debug` when an adapter exposes no setFallbackHandler. Its own comment scheduled a move to `warn` "when that flip lands" -- it has landed, so declared endpoints are now silently unservable there -- but changing the level is a behavior change, so this commit only makes the comment truthful and names the tracking issue. Refs #5231, #5040, #5078, #5230, #5385 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VkPSGsX9o17MsGv3Lbxu2w --- packages/metadata/src/metadata-manager.ts | 11 +- packages/rest/src/openapi-endpoints.test.ts | 13 +- packages/rest/src/openapi-endpoints.ts | 24 +- packages/rest/src/rest-openapi-route.test.ts | 8 +- packages/rest/src/rest-server.ts | 10 +- .../runtime/src/api-endpoint-step.test.ts | 6 +- packages/runtime/src/api-endpoint-step.ts | 13 +- packages/runtime/src/api-mapping.test.ts | 7 +- ...ugin.endpoint-fallback.integration.test.ts | 9 +- ....multi-tenant-endpoint.integration.test.ts | 290 ++++++++++++++++++ packages/runtime/src/dispatcher-plugin.ts | 20 +- packages/runtime/src/endpoint-executor.ts | 22 +- packages/runtime/src/endpoint-policy.ts | 13 +- packages/runtime/src/route-ledger.ts | 9 +- 14 files changed, 395 insertions(+), 60 deletions(-) create mode 100644 packages/runtime/src/dispatcher-plugin.multi-tenant-endpoint.integration.test.ts diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index 83539e9884..e1d724e659 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -2161,9 +2161,14 @@ export class MetadataManager implements IMetadataService { * already resolve the `metadata` service for the environment they serve — * adding one here would create a second scoping mechanism. * - * Nothing reaches this method over HTTP in 17.x: the dispatcher seam is - * #5090's, and publish still rejects a non-empty `apis:` (#4936), so the - * whole path is structurally unreachable until the #5040 E7 flip. + * This method is reached over HTTP on a real boot. The dispatcher seam + * landed as #5090 (`packages/runtime/src/api-endpoint-step.ts`, called from + * the `setFallbackHandler` the dispatcher plugin installs), and #4936's + * wholesale publish refusal of a non-empty `apis:` was replaced by the + * #5040 E7 per-shape gates (`packages/spec/src/api/endpoint-publish-gate.ts`) + * — so declarations exist and requests arrive here. The showcase's two + * declared endpoints are matched and executed through this path in + * `packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`. * * @throws when the metadata store cannot be read — an outage must never be * reported as a miss, because a miss becomes a 404. diff --git a/packages/rest/src/openapi-endpoints.test.ts b/packages/rest/src/openapi-endpoints.test.ts index 908ac50ae4..4daada8265 100644 --- a/packages/rest/src/openapi-endpoints.test.ts +++ b/packages/rest/src/openapi-endpoints.test.ts @@ -3,14 +3,17 @@ /** * #5040 E6 — declared endpoints in the OpenAPI document. * - * Two jobs, and the second one is the load-bearing one TODAY: + * Two jobs: * * 1. the positive shapes, driven straight through the pure enrichment with - * parsed declarations (publish still refuses to let any of them exist, so - * there is no boot that could exercise them end to end yet); + * parsed declarations. Since the #5040 E7 publish flip these are the LIVE + * path — a real showcase boot serves an `/openapi.json` carrying its two + * declared endpoints + * (`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`); + * the cases here pin the projection itself, apart from any boot; * 2. the empty-set invariant — with no declarations the document must come - * back not merely equivalent but IDENTICAL, because that is the entire - * live-behaviour claim this change makes until the E7 flip. + * back not merely equivalent but IDENTICAL, which is what keeps a + * deployment that declares nothing byte-for-byte unchanged. */ import { describe, it, expect, vi } from 'vitest'; diff --git a/packages/rest/src/openapi-endpoints.ts b/packages/rest/src/openapi-endpoints.ts index a016f10a4a..1f1eb930e9 100644 --- a/packages/rest/src/openapi-endpoints.ts +++ b/packages/rest/src/openapi-endpoints.ts @@ -55,12 +55,20 @@ * the mirror; it is not built here because Prime Directive #2 keeps logic out * of `packages/spec` and the frozen vocabulary is not this unit's to widen. * - * ## Today it emits nothing + * ## What it emits today * - * Publish/validate still rejects a non-empty `apis:` until the E7 flip, so the - * enumeration yields an empty set and {@link enrichOpenApiWithEndpoints} - * returns its input document BY REFERENCE — the served bytes are identical to - * before this change. That invariant is pinned by a test rather than argued. + * Real documents. The #5040 E7 publish flip + * (`packages/spec/src/api/endpoint-publish-gate.ts`) ended the wholesale + * refusal of a non-empty `apis:`, so the enumeration is no longer empty on a + * deployment that declares endpoints: a real showcase boot serves an + * `/openapi.json` describing its two declared endpoints + * (`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`). + * + * The empty-set case is still exact rather than approximate — with nothing to + * add, {@link enrichOpenApiWithEndpoints} returns its input document BY + * REFERENCE — which is what keeps a deployment that declares no endpoint + * byte-identical to one built before this module existed. That invariant is + * pinned by a test rather than argued. */ import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; @@ -328,8 +336,10 @@ export function selectDocumentableEndpoints( * Fold declared endpoints into an OpenAPI document's `paths`. * * Returns `doc` ITSELF when there is nothing to add — that is what makes the - * empty-set case byte-identical rather than merely equivalent, and it is the - * state of the world until the E7 flip lets a non-empty `apis:` publish. + * empty-set case byte-identical rather than merely equivalent, which is the + * state a deployment declaring no endpoint stays in. Since the E7 flip let a + * non-empty `apis:` publish, the other branch is the live one wherever + * endpoints are declared. * * A declaration never displaces a built-in: if the document already describes * the same path+method, the built-in keeps it and the declaration is reported. diff --git a/packages/rest/src/rest-openapi-route.test.ts b/packages/rest/src/rest-openapi-route.test.ts index 33488b2e13..4c65e8dd3c 100644 --- a/packages/rest/src/rest-openapi-route.test.ts +++ b/packages/rest/src/rest-openapi-route.test.ts @@ -77,9 +77,11 @@ describe('GET /api/v1/openapi.json — endpoint enrichment', () => { }); it('serves a document identical to the pre-#5093 one while no endpoint is declared', async () => { - // The load-bearing invariant: publish rejects a non-empty `apis:` until the - // E7 flip, so this is the ONLY state that exists in production today, and - // the change is required to be invisible in it. Compared against the same + // The load-bearing invariant: a deployment that declares no endpoint must + // not be able to tell the enrichment step exists. Since the #5040 E7 + // publish flip this is no longer the only state in production — endpoints + // do publish — which is exactly why the no-declaration state needs pinning + // rather than assuming. Compared against the same // handler fed a protocol with no `api` capability at all — i.e. the world // exactly as it was before the enrichment step existed. const withEmptyApis = await serveOpenApi(makeProtocol({ object: [], api: [] }).protocol); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index a0b23d19d4..910b0c1b8a 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2862,10 +2862,12 @@ export class RestServer { // ADR-0076): declared endpoints join it here rather than // growing a second generator somewhere else. // - // Until the E7 flip a non-empty `apis:` cannot publish, so - // the enumeration is empty and `enrichOpenApiWithEndpoints` - // hands `enriched` straight back — the served bytes today - // are exactly the ones served before this change. + // Since the E7 flip a non-empty `apis:` publishes, so this + // enumeration returns real declarations on a deployment that + // has them and the document grows a path entry per endpoint. + // Where nothing is declared the enumeration is empty and + // `enrichOpenApiWithEndpoints` hands `enriched` straight + // back, byte for byte. try { const apiResult = await protocol?.getMetaItems?.({ type: 'api' }); const apiItems: unknown[] = Array.isArray((apiResult as any)?.items) diff --git a/packages/runtime/src/api-endpoint-step.test.ts b/packages/runtime/src/api-endpoint-step.test.ts index 3863cb4003..06d9ad3d88 100644 --- a/packages/runtime/src/api-endpoint-step.test.ts +++ b/packages/runtime/src/api-endpoint-step.test.ts @@ -5,8 +5,10 @@ * * Every case here is about ONE question: when does this step answer, and when * does it write nothing so the transport's existing unmatched answer stands? - * Getting that wrong in either direction is a live behavior change on a surface - * that is supposed to be inert until the #5040 E7 flip. + * Getting that wrong in either direction is a live behavior change — and since + * the #5040 E7 publish flip it is live for real traffic, not just in principle: + * endpoints can be declared, so a step that answers when it should stay silent + * now shadows the transport's 404 on a deployment. * * `matchEndpoint` is driven by a stub implementing the contract in * `@objectstack/spec/contracts` — deliberately, not by the real matcher: that diff --git a/packages/runtime/src/api-endpoint-step.ts b/packages/runtime/src/api-endpoint-step.ts index 73b79eef9c..36b4156b2b 100644 --- a/packages/runtime/src/api-endpoint-step.ts +++ b/packages/runtime/src/api-endpoint-step.ts @@ -20,11 +20,14 @@ * ## What it does today, and what it does not * * On a match it runs the WHOLE chain: policies (#5040 E4) and then target - * execution (#5040 E5), wired together here by E5b. It is still structurally - * unreachable — a non-empty `apis:` is rejected at publish / validate until the - * E7 flip — so no deployment can observe it; the tests drive `matchEndpoint` - * through a stub, exactly as #5040 §5 prescribes for every E-series unit that - * lands before the flip. + * execution (#5040 E5), wired together here by E5b. Since the E7 publish flip + * (`packages/spec/src/api/endpoint-publish-gate.ts`) a non-empty `apis:` is no + * longer refused wholesale — only shape by shape — so a deployment CAN observe + * this step: the showcase declares two endpoints and a real boot serves them + * (`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`). + * The tests here still drive `matchEndpoint` through a stub, which is what + * keeps the decision (when to answer, when to write nothing) testable apart + * from the wiring. * * ## The chain, in the one order it can run in * diff --git a/packages/runtime/src/api-mapping.test.ts b/packages/runtime/src/api-mapping.test.ts index fe92cb7d52..32686cc5b4 100644 --- a/packages/runtime/src/api-mapping.test.ts +++ b/packages/runtime/src/api-mapping.test.ts @@ -14,9 +14,10 @@ * applied. `transform` is the case #5137 was filed over, but an unusable * path and colliding targets are the same category and answer identically. * - * And the property that makes both safe to land before the E7 flip: with no - * declaration, the value that goes in is the value that comes out, by - * reference. + * And the identity property both rest on: with no declaration, the value that + * goes in is the value that comes out, by reference. That is what made these + * keys safe to land ahead of the #5040 E7 publish flip, and it is still what + * keeps an endpoint declaring no mapping free of any projection cost. */ import { describe, it, expect } from 'vitest'; diff --git a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts index a2a47eea0c..65f5624b15 100644 --- a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts +++ b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts @@ -18,9 +18,12 @@ * rides a success and never an error. * * The load-bearing assertion in most of these is a NEGATIVE one: that adding - * this seam changed nothing for anybody. Today's unmatched answers — the bare - * 404 and the 405 + `Allow` — must come back byte for byte, since a stack - * cannot declare an endpoint at all until the E7 flip. + * this seam changed nothing for anybody who did not ask for it. The unmatched + * answers — the bare 404 and the 405 + `Allow` — must come back byte for byte + * for every path no declaration owns. Since the #5040 E7 publish flip that is + * the assertion's whole weight: stacks CAN declare endpoints now, so "the + * fallback stays silent unless a declaration matches" is a promise to live + * deployments rather than a property of a surface nothing could reach. * * NOTE on the body guarantee: that the fallback receives a READABLE `req.body` * (the difference from the `use()` middleware seam) is a transport promise, and diff --git a/packages/runtime/src/dispatcher-plugin.multi-tenant-endpoint.integration.test.ts b/packages/runtime/src/dispatcher-plugin.multi-tenant-endpoint.integration.test.ts new file mode 100644 index 0000000000..9d971096b2 --- /dev/null +++ b/packages/runtime/src/dispatcher-plugin.multi-tenant-endpoint.integration.test.ts @@ -0,0 +1,290 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The declarative-endpoint fallback on a MULTI-TENANT host (#5040 E5b, #5399). + * + * ## The branch this file exists for + * + * `dispatcher-plugin.ts`'s `setFallbackHandler` asks the host's resolver to + * place each request in an environment BEFORE it probes `matchEndpoint`. When + * that placement fails on a multi-tenant host it DECLINES — writes nothing, so + * the transport's own 404 stands — and warns. Serving it from the default + * kernel instead would answer one tenant's URL out of another tenant's data. + * + * Until #5399 that branch had zero test references anywhere in the repo, for a + * structural reason rather than an oversight: the `kernel-resolver` PROVIDER + * ships in the cloud distribution, so this repo holds only consumers and no + * real boot here ever resolves a second environment. A stub resolver is the + * only way to reach it — the same posture + * `http-dispatcher.multi-tenant-concurrency.test.ts` (#5155) takes, moved up a + * layer: that file drives `HttpDispatcher` directly, this one drives the + * composed plugin over a real socket, because the branch under test lives in + * the plugin's fallback closure and not in the dispatcher at all. + * + * ## Why a real boot rather than calling the closure + * + * The handler is a closure created inside `start()`; no seam hands it to a + * caller. Reaching it any other way would mean re-implementing the + * composition, which is the shortcut ADR-0076's "who serves this path" note + * (and #4073, answered wrongly three times) says not to take. + * + * ## The topology, and why the HOST also declares the endpoint + * + * Every kernel here — the two tenants AND the host — carries a `metadata` slot + * declaring the same path, and an `automation` slot tagged with its owner. The + * host's copy is what gives the decline branch something to prevent: a request + * that cannot be placed keeps `context.kernel` as the DEFAULT kernel, so + * without the branch the fallback would probe the host's declaration, match it, + * and answer 200 out of the host — one tenant's URL served from another + * kernel's data. + * + * That second copy is not decoration; it is the difference between a test and a + * tautology. The first draft of this file declared the endpoint on the tenants + * only, and reverse-verification (branch deleted, suite re-run) showed the + * decline cases stayed GREEN: with nothing declared on the host the step simply + * declined again for an unrelated reason — no `metadata` slot on the request's + * kernel — so the 404 and the never-probed assertions proved nothing, and only + * the `warn` moved. The topology below is the one in which every assertion + * moves. + * + * Service resolution reaches those copies by two different routes, which is + * also why both cases are meaningful: + * + * - `metadata` is resolved WITH the environment id. The host-scoped lookup it + * tries first answers nothing on a LiteKernel (no scoped factory is + * registered, unlike the cloud host this models), so it falls through to the + * request's own kernel — the tenant's copy on a placed request, the host's + * copy on an unplaced one. + * - `automation` is resolved WITHOUT one and reads `context.kernel` directly. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { LiteKernel, Plugin, PluginContext } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; +import type { ApiEndpointMatch, IHttpServer } from '@objectstack/spec/contracts'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +const PATH = '/api/v1/apps/showcase/tenant-probe'; +const FLOW = 'tenant_probe_flow'; + +/** + * The one declared endpoint. + * + * `authRequired: false` + an ARMED `rateLimit` is the single anonymous shape + * ADR-0121 D6 permits (an unarmed budget meters nothing, so the gate requires + * `enabled: true`). Declared that way here so the fixture is a shape publish + * would actually accept, rather than one that survives only because a stub + * matcher skips the gates. + */ +const ENDPOINT: ApiEndpoint = ApiEndpointSchema.parse({ + name: 'tenant_probe', + path: PATH, + method: 'GET', + type: 'flow', + target: FLOW, + authRequired: false, + rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 1_000 }, +}); + +/** Every `matchEndpoint` query the boot made, tagged with who answered it. */ +const matchQueries: Array<{ owner: string; path: string; method: string }> = []; + +/** One matcher body, so host and tenants differ only by their tag. */ +function makeMatcher(owner: string) { + return async (q: { path: string; method: string }): Promise => { + matchQueries.push({ owner, path: q.path, method: q.method }); + const hit = q.path.replace(/\/$/, '') === ENDPOINT.path && q.method.toUpperCase() === ENDPOINT.method; + return hit ? { endpoint: ENDPOINT, params: {} } : undefined; + }; +} + +/** + * The HOST's `metadata` + `automation` slots — the counterfactual. + * + * A placed request must never reach these; an unplaced one would reach both if + * the decline branch were gone, and would come back 200 saying `servedBy: + * 'host'`. Occupying `automation` too is what makes that failure mode a plain + * wrong answer rather than a 501 that could be mistaken for an unrelated gap. + */ +function hostSlotsPlugin(): Plugin { + return { + name: 'com.objectstack.test.host-slots', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('metadata', { list: async () => [], matchEndpoint: makeMatcher('host') }); + ctx.registerService('automation', { + execute: async (flow: string) => ({ servedBy: 'host', flow }), + }); + }, + }; +} + +/** + * A per-environment kernel carrying only the two slots the chain reads. + * + * Smaller than a kernel on purpose: anything else it grew could be read by + * accident and would weaken the "served by THIS kernel" claim. + */ +function makeTenantKernel(tenant: string) { + const services: Record = { + metadata: { __tag: tenant, list: async () => [], matchEndpoint: makeMatcher(tenant) }, + automation: { __tag: tenant, execute: async (flow: string) => ({ servedBy: tenant, flow }) }, + }; + return { + __tag: tenant, + getServiceAsync: async (name: string) => services[name] ?? null, + getService: (name: string) => services[name] ?? null, + context: { getService: (name: string) => services[name] ?? null }, + }; +} + +const TENANTS: Record> = { + 'env-1': makeTenantKernel('env-1'), + 'env-2': makeTenantKernel('env-2'), +}; + +/** Every environment id the resolver was asked to place. */ +const resolverCalls: Array = []; + +/** + * The host's `kernel-resolver` — the ADR-0006 Phase 5 seam the cloud + * distribution fills. Registering it at all is what makes + * `HttpDispatcher.isMultiTenantHost()` true, which is the branch's guard. + * + * Registered in `init()` because the dispatcher plugin resolves it during + * `start()` when it constructs the `HttpDispatcher`; a `start()`-registered + * resolver would be visible or not depending on plugin order. + * + * The decline is expressed exactly as the contract says: return `undefined` + * and leave `context.environmentId` unset. + */ +function tenantResolverPlugin(): Plugin { + return { + name: 'com.objectstack.test.kernel-resolver', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('kernel-resolver', { + resolveKernel: async (context: any) => { + const headers = (context?.request?.headers ?? {}) as Record; + const envId = headers['x-environment-id']; + resolverCalls.push(envId); + const kernel = envId ? TENANTS[envId] : undefined; + if (!kernel) return undefined; + context.environmentId = envId; + return kernel; + }, + }); + }, + }; +} + +let kernel: LiteKernel | undefined; +let baseUrl = ''; + +/** Capture the boot's log lines without swallowing them. */ +let stdoutLines: string[] = []; +let restoreStdout: (() => void) | undefined; + +function captureStdout() { + const original = process.stdout.write.bind(process.stdout); + const spy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: any, ...rest: any[]) => { + stdoutLines.push(String(chunk)); + return (original as any)(chunk, ...rest); + }) as any); + restoreStdout = () => spy.mockRestore(); +} + +beforeAll(async () => { + kernel = new LiteKernel(); + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); + kernel.use(hostSlotsPlugin()); + kernel.use(tenantResolverPlugin()); + kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false })); + await kernel.bootstrap(); + const httpServer = kernel.getService('http.server'); + baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; +}, 60_000); + +afterAll(async () => { + restoreStdout?.(); + if (!kernel) return; + await Promise.race([ + kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); +}, 60_000); + +describe('#5040 E5b — a placed request executes on ITS OWN resolved kernel', () => { + it('delegates to the resolved tenant\'s automation slot, not the host\'s', async () => { + matchQueries.length = 0; + + const res = await fetch(`${baseUrl}${PATH}`, { headers: { 'X-Environment-Id': 'env-1' } }); + + expect(res.status).toBe(200); + // `servedBy` can only be 'env-1' if the delegation read env-1's kernel. + // The host's own automation slot is occupied and must not answer. + expect(await res.json()).toEqual({ success: true, data: { servedBy: 'env-1', flow: FLOW } }); + expect(matchQueries).toEqual([{ owner: 'env-1', path: PATH, method: 'GET' }]); + }, 60_000); + + it('serves a second tenant the SAME path out of its own kernel', async () => { + matchQueries.length = 0; + + const res = await fetch(`${baseUrl}${PATH}`, { headers: { 'X-Environment-Id': 'env-2' } }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true, data: { servedBy: 'env-2', flow: FLOW } }); + expect(matchQueries).toEqual([{ owner: 'env-2', path: PATH, method: 'GET' }]); + }, 60_000); +}); + +describe('#5040 E5b — multi-tenant resolution finds no environment: decline + warn', () => { + it('answers the transport\'s own 404 and never probes the declaration', async () => { + matchQueries.length = 0; + resolverCalls.length = 0; + stdoutLines = []; + captureStdout(); + + // No `X-Environment-Id`: the resolver cannot place the request, so it + // returns undefined and leaves `environmentId` unset. + const res = await fetch(`${baseUrl}${PATH}`); + restoreStdout?.(); + + // The resolver WAS asked — this is the decline branch, not a + // single-environment host that never had a resolver. + expect(resolverCalls).toEqual([undefined]); + + // Declining writes nothing, so the bare Hono 404 stands. The host + // DECLARES this path and could have served it: without the branch this + // is a 200 carrying `servedBy: 'host'`. + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + + // The decline happened BEFORE the probe. A declaration must never be + // consulted for a request that was not placed in an environment. + expect(matchQueries).toEqual([]); + + const warned = stdoutLines.filter( + (line) => line.includes('WARN') && line.includes('resolved to no environment'), + ); + expect(warned).toHaveLength(1); + // The warning names the path, so an operator can tell WHICH request + // was dropped rather than only that one was. + expect(warned[0]).toContain(PATH); + expect(warned[0]).toContain('declining rather than serving it from the default kernel'); + }, 60_000); + + it('declines an UNKNOWN environment the same way — a header is not a placement', async () => { + matchQueries.length = 0; + resolverCalls.length = 0; + + const res = await fetch(`${baseUrl}${PATH}`, { headers: { 'X-Environment-Id': 'env-does-not-exist' } }); + + expect(resolverCalls).toEqual(['env-does-not-exist']); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + expect(matchQueries).toEqual([]); + }, 60_000); +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 14f02a7f84..abf8aa47e1 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1426,16 +1426,22 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu ctx.logger.info('Declarative endpoint dispatch step armed', { mount: appEndpointMountPrefix(prefix), // True as of #5040 E5b: policy chain + target execution are - // wired. Nothing can be DECLARED until the E7 publish flip, so - // this still describes a surface no deployment can reach. + // wired. And reachable as of the E7 publish flip — a + // non-empty `apis:` publishes, so this describes a surface + // deployments actually serve. executes: true, }); } else { - // `debug`, not `warn`: no stack can declare an endpoint yet (a - // non-empty `apis:` is rejected at publish until #5040 E7), so - // nothing is missing from any deployment today. When that flip - // lands, THIS is where absence must become loud — an adapter - // without the seam can never serve a declared endpoint. + // Still `debug`, and that is now UNDER-STATED — tracked by + // #5400, deliberately not changed here (#5399 is comment-only). + // The reason this was debug has expired: it read "no stack can + // declare an endpoint yet", which stopped being true at the + // #5040 E7 publish flip. Declarations exist now, so on an + // adapter without this seam they are silently unservable and + // the operator's only signal is a line that the default `info` + // level does not even print. This is the "absence must be loud" + // case (AGENTS.md, Route & surface ownership §3); #5400 raises + // it to `warn` carrying the consequence and the remedy. ctx.logger.debug( '[dispatcher] http.server exposes no `setFallbackHandler`; declarative endpoints ' + 'would be unreachable on this transport.', diff --git a/packages/runtime/src/endpoint-executor.ts b/packages/runtime/src/endpoint-executor.ts index 3601dbadfb..f70a1999e0 100644 --- a/packages/runtime/src/endpoint-executor.ts +++ b/packages/runtime/src/endpoint-executor.ts @@ -20,18 +20,18 @@ * contributes is only: which pipeline, with which arguments, and how its answer * (or its throw) becomes an HTTP response. * - * ## Nothing calls this yet + * ## What calls this, and since when * - * The dispatch step (`api-endpoint-step.ts`, #5090) still answers 501 on a - * match. Replacing that branch with `policy → execute` is a deliberate - * follow-up single, landing after this and #5091 (E4, policy keys) are both on - * `main` — the two were developed in parallel on disjoint file surfaces (#4604), - * and wiring them together is one small reviewable change rather than a race - * between two PRs over the same lines. Until that lands this module is - * unreachable, and on top of that a non-empty `apis:` is still rejected at - * publish/validate until the E7 flip. Zero live behavior change; the tests drive - * it directly with stubs, exactly as #5040 §5 prescribes for every E-series unit - * that lands before the flip. + * The dispatch step (`api-endpoint-step.ts`, #5090) calls it on every match: + * E5b replaced that step's provisional 501 with `policy → execute`, so this + * module runs whenever a declared endpoint is matched. The E7 publish flip + * (`packages/spec/src/api/endpoint-publish-gate.ts`) then made declarations + * possible at all — a non-empty `apis:` is no longer refused wholesale, only + * shape by shape — so this is LIVE code on a real deployment, not a unit + * waiting for its seam. The showcase exercises it end to end over a socket + * (`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`); + * the unit tests below still drive it directly with stubs, which is how the + * exact delegated call shape stays assertable. * * ## Pure by construction * diff --git a/packages/runtime/src/endpoint-policy.ts b/packages/runtime/src/endpoint-policy.ts index 7c0225a48c..1c25bdb409 100644 --- a/packages/runtime/src/endpoint-policy.ts +++ b/packages/runtime/src/endpoint-policy.ts @@ -46,11 +46,16 @@ * with anonymous traffic from the same address), then the meter runs, then the * gate. One lookup, used twice. * - * ## Structurally unreachable today + * ## Live on a real deployment * - * A non-empty `apis:` is rejected at publish / validate until the #5040 E7 flip, - * so nothing here can be observed by a deployment. The tests drive it directly, - * as #5040 §5 prescribes for every E-series unit landing before the flip. + * The #5040 E7 flip (`packages/spec/src/api/endpoint-publish-gate.ts`) ended + * the wholesale refusal of a non-empty `apis:`, so these keys now gate real + * traffic: `api-endpoint-step.ts` applies this chain on every match, and the + * showcase's two declared endpoints exercise it over a socket + * (`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts` + * pins that `authRequired` denies anonymous and `cacheTtl` reaches the wire). + * The tests below still drive this module directly — a pure function over + * explicit deps is worth testing as one. */ import { diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 3a5b57f439..7b1731eea3 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -295,9 +295,12 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ + 'service. `script` / `proxy` targets and the inputMapping / outputMapping keys are NOT ' + 'executed and answer 501. A miss (or an occupant of the metadata slot with no matchEndpoint, ' + 'or a multi-tenant request that resolves to no environment) writes nothing, leaving the ' - + 'transport\'s 404/405 answer untouched. Structurally unreachable today: a non-empty `apis:` ' - + 'is rejected at publish until the #5040 E7 flip, so nothing can be declared for this seam to ' - + 'match. No SDK surface — app-declared endpoints are an external-integration channel ' + + 'transport\'s 404/405 answer untouched. LIVE since the #5040 E7 publish flip: a non-empty ' + + '`apis:` no longer fails wholesale, it is gated shape by shape ' + + '(`packages/spec/src/api/endpoint-publish-gate.ts`), so declarations exist and a real boot ' + + 'reaches this seam — the showcase serves two of them ' + + '(`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`). ' + + 'No SDK surface — app-declared endpoints are an external-integration channel ' + '(ADR-0121 D3), called by the integrator\'s own client, not by `@objectstack/client`', },