diff --git a/.changeset/liveness-carved-out-of-the-identity-step.md b/.changeset/liveness-carved-out-of-the-identity-step.md new file mode 100644 index 0000000000..1e7108813a --- /dev/null +++ b/.changeset/liveness-carved-out-of-the-identity-step.md @@ -0,0 +1,13 @@ +--- +"@objectstack/runtime": minor +--- + +`GET /api/v1/health` answers 200 whenever the process can serve HTTP, even while a configuration fault is making every other route 503. + +The dispatcher resolves a per-request identity before any route handler runs, and that step reads the tenancy posture for every request — credentialed or not. Since a `tenancy` service that is registered and fails to build is (correctly) re-raised as a 503 rather than absorbed into "there is no posture", an uncredentialed liveness probe was answered 503 for the length of the outage. A liveness probe reads 503 as *restart me*; the service then fails to build again on the new pod. A restart cannot fix a service that cannot build, so the result was a restart loop that hid the very fault the 503 exists to make loud. + +**Liveness is now carved out of the identity step.** `GET /health` runs its handler directly: no identity resolution, no configuration read, no credential read — the payload it answers (`status`, `timestamp`, `version`, `uptime`) was already process-local. Wire it to `livenessProbe`. + +**Readiness is unchanged, deliberately.** `GET /ready` keeps the full identity step and its 503 body, so traffic is withheld until the fault is fixed and existing operator dashboards keep the signal they have. Wire it to `readinessProbe`. Nothing else about the 503 moved: every other route, and an environment-scoped `/environments/:id/health`, answers exactly as before. + +Which routes count as liveness is **derived from the dispatcher's own route table** rather than listed anywhere: a route declares `liveness: true` on its registry entry, and `DomainHandlerRegistry.resolveLiveness()` answers through the same matcher that picks the handler — so the set cannot drift from the routes that exist. `DomainRoute.liveness` and `resolveLiveness()` are additive public surface on `@objectstack/runtime`; a route that does not declare the flag is untouched. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 45bc3dbab9..7b81536d88 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -225,6 +225,15 @@ os serve --preset minimal # Skip auto-loaded auth/i18n/ui plugins - `--preset minimal | default | full` — Override the auto-registration tier (see below) +**Probes.** A served process exposes `GET /api/v1/health` (liveness — process +only, and deliberately blind to configuration and credentials, so a +configuration fault never restarts the pod) and `GET /api/v1/ready` (readiness — +the full request pipeline, answering `503` while booting, draining, or faulted). +Wire the first to Kubernetes' `livenessProbe` and the second to +`readinessProbe`, never the reverse — the field mapping and the reference +manifest live in +[Health checks & orchestration](/docs/deployment/self-hosting#health-checks--orchestration). + **Tier presets** `os serve` decides which optional plugins to auto-register from a tier diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index a6039a28fc..0533e0d118 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -265,6 +265,20 @@ questions: inconclusive — no data engine at all, or the probe itself errors — the replica stays ready rather than black-holing a working deployment. +**A configuration fault never restarts the pod.** The split above is enforced +one step earlier than the two handlers: `/health` is served *before* the request +pipeline resolves an identity, so it evaluates nothing that depends on +configuration or credentials — a misconfigured or unbuildable security service +leaves it answering `200` for as long as the process can serve HTTP. `/ready` +keeps the whole pipeline and answers `503` for exactly that class of fault, with +the body it has always returned. So wire `path: /api/v1/health` to +`livenessProbe` and `path: /api/v1/ready` to `readinessProbe` — **never the +other way round, and never the same path to both**. Crossed over, a +configuration fault makes the orchestrator kill and recreate the pod; the new +one reads the same configuration and fails the same way, and the operator sees +`CrashLoopBackOff` instead of the fault. Withholding traffic is the response +that helps; restarting is not. + ### Kubernetes The same image works unchanged. A minimal reference Deployment — secrets from diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index 166a7c42be..e6a4e863db 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -68,6 +68,27 @@ export interface DomainRoute { match?: 'prefix' | 'exact' | 'segment'; /** Restrict to these UPPERCASE HTTP methods. Omit = all methods. */ methods?: string[]; + /** + * This route is a LIVENESS probe: `dispatch()` runs its handler WITHOUT the + * per-request identity step or the gates that follow it. + * + * Declared here, on the route itself, and nowhere else — that is the whole + * point of the field. "Which routes are liveness" is a question with exactly + * one honest source, the table `dispatch()` already routes on, so + * {@link DomainHandlerRegistry.resolveLiveness} answers it through the SAME + * matcher that picks the handler. A separate array of liveness paths would + * be a second list of routes, and this repo has measured what those cost: + * they drift from the thing they describe and the drift is silent. + * + * ⛔ Do not set this on a route whose body reads configuration, credentials + * or any service. A liveness handler may report process-local facts only + * (the process is executing code, the server is listening) — anything else + * puts a configuration fault back on the route whose consumer answers by + * restarting the pod, and a restart cannot fix a service that cannot build. + * Readiness is where a dependency check belongs; its failure mode (leave the + * load-balancer rotation) is the one that helps. + */ + liveness?: boolean; handler: DomainHandler; } @@ -302,6 +323,23 @@ export class DomainHandlerRegistry { return undefined; } + /** + * The route claiming `path` (+`method`) when — and only when — it declared + * itself a liveness probe ({@link DomainRoute.liveness}); otherwise + * `undefined`. + * + * DERIVED, not listed: it is {@link resolve} plus one field read, so the + * liveness set is a projection of the live route table and cannot name a + * route that is not registered, miss one that is, or disagree with the + * matcher about which route a path reaches. First-match-wins is inherited + * too — a non-liveness route registered earlier shadows here exactly as it + * shadows in `resolve`, because that is the route the request would get. + */ + resolveLiveness(path: string, method: string): DomainRoute | undefined { + const route = this.resolve(path, method); + return route?.liveness ? route : undefined; + } + private static matches(route: DomainRoute, path: string): boolean { switch (route.match) { case 'exact': diff --git a/packages/runtime/src/http-dispatcher.liveness-carve-out.test.ts b/packages/runtime/src/http-dispatcher.liveness-carve-out.test.ts new file mode 100644 index 0000000000..2b46e58b4b --- /dev/null +++ b/packages/runtime/src/http-dispatcher.liveness-carve-out.test.ts @@ -0,0 +1,305 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15910 — maintainer ruling 2026-09-06, decision batch #57, option C, 「同意」] + * + * > Carve liveness out of the identity step. `/health` (liveness) answers 200 + * > whenever the process can serve HTTP, regardless of configuration faults; + * > `/ready` (readiness) keeps returning 503 for the identity/configuration + * > fault so traffic is withheld until the fault is fixed. A configuration + * > fault must never restart a pod that cannot be fixed by restarting. + * + * ## The behaviour this pins, and why one-sided assertions do not discriminate + * + * #15909 (merged) made the identity step re-raise the ONE fault that must stay + * loud: a `tenancy` service that is registered and FAILED TO BUILD is no longer + * absorbed into "there is no posture". `HttpDispatcher.resolveRequestScope` + * reads that posture for EVERY request, credentialed or not, and ran before any + * domain handler — so an uncredentialed `GET /health` went 200 → 503 for the + * duration of the outage. A liveness probe reads 503 as "restart me"; the + * tenancy service then fails to build again; and the restart loop hides the + * fault it was made loud to expose. + * + * The ruling does not soften the 503 — it moves it off the one route whose + * consumer answers by killing the process. So the acceptance shape is a PAIR, + * asserted together against ONE forced fault: `/health` 200 **and** `/ready` + * 503 simultaneously. Either half alone passes on a tree that is still wrong — + * `/health` 200 alone is satisfied by deleting the re-raise outright, `/ready` + * 503 alone is satisfied by `origin/main`. + * + * And a 503 on `/ready` is not self-describing: the readiness handler has a 503 + * of its own ("Service not ready", while the kernel is not `running`). The + * legs below therefore assert which 503 it is — the identity step's, or the + * handler's — so "readiness keeps the full identity step and its 503 body + * unchanged" is measured rather than assumed. + * + * ## The anti-drift half of the ruling + * + * "Which routes are liveness" is DERIVED from the dispatcher's own route table + * (`DomainHandlerRegistry.resolveLiveness` = `resolve` + one field read), never + * written down again as a list of paths. `§3` pins that by registering a NEW + * liveness route through the public seam and driving it through the same fault: + * a hard-coded `'/health'` test in `dispatch()` would fail there while every + * other leg in this file stayed green. + */ + +import { describe, it, expect } from 'vitest'; + +import { ObjectKernel } from '@objectstack/core'; +import { ApiErrorSchema, BaseResponseSchema } from '@objectstack/spec/api'; + +import { HttpDispatcher } from './http-dispatcher.js'; +import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import { DomainHandlerRegistry } from './domain-handler-registry.js'; + +/** + * A REAL kernel, as the host hands it to the dispatcher — so the rejection under + * test is the service registry's own (branded on "never registered", UNBRANDED + * on "registered and could not be built"), not a stub's imitation of one. + * + * `gracefulShutdown: false` — a fixture kernel must not hook the test runner's + * process signals. + */ +function kernelWith(tenancy: 'failed' | 'healthy'): ObjectKernel { + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as any); + kernel.registerService('objectql', { find: async () => [] }); + if (tenancy === 'healthy') { + kernel.registerService('tenancy', { posture: 'isolated' }); + } else { + // The REAL failure class the ruling is about: a service that IS + // registered and throws while being built. + kernel.registerServiceFactory('tenancy', () => { + throw new Error('tenancy backend unavailable'); + }); + } + return kernel; +} + +const kernelWithFailedTenancy = () => kernelWith('failed'); +const kernelWithHealthyTenancy = () => kernelWith('healthy'); + +/** A fake `IHttpServer` recording the handlers the dispatcher plugin mounts. */ +function makeFakeServer() { + const handlers: Record any> = {}; + const rec = (verb: string) => (path: string, handler: any) => { handlers[`${verb} ${path}`] = handler; }; + return { + handlers, + server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') }, + }; +} + +async function mountOn(kernel: ObjectKernel) { + const { server, handlers } = makeFakeServer(); + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.({ + getKernel: () => kernel, + getService: (n: string) => (n === 'http.server' ? server : undefined), + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, on: () => {}, + } as any); + return handlers; +} + +async function drive(handler: (req: any, res: any) => any, req: any) { + expect(handler, 'route must be mounted').toBeTypeOf('function'); + const res: any = { + statusCode: undefined, body: undefined, + status(c: number) { res.statusCode = c; return res; }, + header() { return res; }, + json(b: any) { res.body = b; return res; }, + end() { return res; }, + }; + await handler(req, res); + return { status: res.statusCode as number, body: res.body }; +} + +const HEALTH = 'GET /api/v1/health'; +const READY = 'GET /api/v1/ready'; + +/** What an orchestrator's probe sends: no credential, no query. */ +const anonymous = { headers: {}, query: {} }; + +/** Settle to the rejection, or to `undefined` when the call RESOLVED. */ +const rejectionOf = (p: Promise) => p.then(() => undefined, (e) => e); + +// --------------------------------------------------------------------------- +// §1 — the ruling's acceptance test: ONE fault, BOTH probes, asserted together +// --------------------------------------------------------------------------- + +describe('[#15910] a forced identity fault: `/health` 200 and `/ready` 503, simultaneously', () => { + it('THE PAIR: on ONE kernel with a registered-and-failing `tenancy`, the uncredentialed probes answer 200 / 503', async () => { + // SUPERSEDED PIN, quoted — what origin/main answered on this wiring + // (measured by reverting the carve-out on this branch): + // expect({ health: 503, ready: 503 }) + // i.e. the liveness probe told the orchestrator to restart a pod whose + // fault no restart can fix. + const handlers = await mountOn(kernelWithFailedTenancy()); + + const health = await drive(handlers[HEALTH], anonymous); + const ready = await drive(handlers[READY], anonymous); + + // Asserted as ONE object on purpose: a tree that fixed only the + // liveness side, or only the readiness side, cannot satisfy this line. + expect({ health: health.status, ready: ready.status }).toEqual({ health: 200, ready: 503 }); + }); + + it('the 200 is the real liveness PAYLOAD, not an empty body that happens to carry a 200', async () => { + const handlers = await mountOn(kernelWithFailedTenancy()); + const { status, body } = await drive(handlers[HEALTH], anonymous); + expect(status).toBe(200); + expect(BaseResponseSchema.safeParse(body).success).toBe(true); + expect(body?.success).toBe(true); + expect(body?.data?.status).toBe('ok'); + // Process-local signals only — the ruling's first execution note. The + // whole payload is answerable without reading configuration. + expect(typeof body?.data?.uptime).toBe('number'); + expect(typeof body?.data?.timestamp).toBe('string'); + }); + + it('READINESS IS UNCHANGED: the 503 is the identity step\'s own envelope, not the handler\'s "Service not ready"', async () => { + const handlers = await mountOn(kernelWithFailedTenancy()); + const { status, body } = await drive(handlers[READY], anonymous); + expect(status).toBe(503); + expect(BaseResponseSchema.safeParse(body).success).toBe(true); + expect(body?.success).toBe(false); + const parsed = ApiErrorSchema.safeParse(body?.error); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(body?.error?.code).toBe('SERVICE_UNAVAILABLE'); + // WHICH 503 this is: the identity step raised before the readiness + // handler ever ran, so the body carries the outage envelope (a 5xx whose + // producer declared it, hence the withheld message) and NOT the + // handler's own verdict, which names its message and its kernel state. + expect(body?.error?.message).toBe('Internal server error'); + expect(body?.error?.details).toBeUndefined(); + }); + + it('CONTROL — healthy `tenancy`, same wiring: `/health` still 200, and `/ready` now answers from its own HANDLER', async () => { + // Without this leg the previous one proves nothing: an unstarted fixture + // kernel is `idle`, so `/ready` has a 503 of its own. Here the identity + // step passes, the handler runs, and its verdict is visibly a DIFFERENT + // 503 — which is what makes the fault leg's envelope evidence. + const handlers = await mountOn(kernelWithHealthyTenancy()); + + const health = await drive(handlers[HEALTH], anonymous); + expect(health.status).toBe(200); + + const ready = await drive(handlers[READY], anonymous); + expect(ready.status).toBe(503); + expect(ready.body?.error?.message).toBe('Service not ready'); + expect((ready.body?.error?.details as any)?.state).toBe('idle'); + }); +}); + +// --------------------------------------------------------------------------- +// §2 — the carve-out is ROUTE-SCOPED: nothing else stopped being loud +// --------------------------------------------------------------------------- + +describe('[#15910] the re-raise is unchanged everywhere except the declared liveness route', () => { + it('`/data/task` still raises the outage out of `dispatch()` — #15909 is not softened', async () => { + const err: any = await rejectionOf( + new HttpDispatcher(kernelWithFailedTenancy(), undefined, { enforceProjectMembership: false }) + .dispatch('GET', '/data/task', undefined, {}, { request: { headers: {} } } as any), + ); + expect(err, '`dispatch()` RESOLVED — the outage was absorbed').toBeDefined(); + expect(err.status).toBe(503); + expect(err.code).toBe('SERVICE_UNAVAILABLE'); + }); + + it('`GET /health` resolves through `dispatch()` without the identity step even under the fault', async () => { + const result = await new HttpDispatcher(kernelWithFailedTenancy(), undefined, { enforceProjectMembership: false }) + .dispatch('GET', '/health', undefined, {}, { request: { headers: {} } } as any); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + // The carve-out runs the handler INSTEAD of the preamble, so nothing + // resolved a per-request kernel onto this context. Asserted rather than + // assumed: it is the observable difference between "the identity step + // ran and forgave the fault" and "the identity step did not run". + const context: any = { request: { headers: {} } }; + await new HttpDispatcher(kernelWithFailedTenancy(), undefined, { enforceProjectMembership: false }) + .dispatch('GET', '/health', undefined, {}, context); + expect(context.executionContext).toBeUndefined(); + expect(context.kernel).toBeUndefined(); + }); + + it('`POST /health` is not liveness — the method restriction on the route governs the carve-out too', async () => { + // The route declares `methods: ['GET']`, so nothing claims POST and the + // request takes the ordinary path — which under this fault is the loud + // one. A carve-out matching on the path alone would answer 200 here. + const err: any = await rejectionOf( + new HttpDispatcher(kernelWithFailedTenancy(), undefined, { enforceProjectMembership: false }) + .dispatch('POST', '/health', undefined, {}, { request: { headers: {} } } as any), + ); + expect(err?.status).toBe(503); + }); + + it('the environment-scoped `/environments/:id/health` keeps today\'s behaviour — it is not a wired probe surface', async () => { + // The carve-out sits above the scoped-URL strip deliberately: a scoped + // health URL still resolves its environment and runs both gates, exactly + // as it did before this change. + const err: any = await rejectionOf( + new HttpDispatcher(kernelWithFailedTenancy(), undefined, { enforceProjectMembership: false }) + .dispatch('GET', '/environments/env_alpha/health', undefined, {}, { request: { headers: {} } } as any), + ); + expect(err?.status).toBe(503); + }); +}); + +// --------------------------------------------------------------------------- +// §3 — the liveness set is DERIVED from the route table, never a second list +// --------------------------------------------------------------------------- + +describe('[#15910] "which routes are liveness" is a projection of the route table', () => { + it('a NEW liveness route registered through the public seam is carved out too — no path is hard-coded', async () => { + // The load-bearing leg of the anti-drift constraint. If `dispatch()` + // tested for `'/health'` instead of reading the route's own declaration, + // this is the only test in the file that would fail. + const dispatcher = new HttpDispatcher(kernelWithFailedTenancy(), undefined, { enforceProjectMembership: false }); + dispatcher.registerDomainHandler({ + prefix: '/probe', match: 'exact', methods: ['GET'], liveness: true, + handler: async () => ({ handled: true, response: { status: 200, body: { success: true, data: { status: 'ok' } } } }), + }); + const result = await dispatcher.dispatch('GET', '/probe', undefined, {}, { request: { headers: {} } } as any); + expect(result.response?.status).toBe(200); + }); + + it('a route registered WITHOUT the declaration is not carved out — opting in is the only way in', async () => { + const dispatcher = new HttpDispatcher(kernelWithFailedTenancy(), undefined, { enforceProjectMembership: false }); + dispatcher.registerDomainHandler({ + prefix: '/not-a-probe', match: 'exact', methods: ['GET'], + handler: async () => ({ handled: true, response: { status: 200, body: { success: true } } }), + }); + const err: any = await rejectionOf( + dispatcher.dispatch('GET', '/not-a-probe', undefined, {}, { request: { headers: {} } } as any), + ); + expect(err?.status).toBe(503); + }); + + it('`resolveLiveness` inherits `resolve`\'s matcher and its first-match-wins order', async () => { + const registry = new DomainHandlerRegistry(); + const handler = async () => ({ handled: true, response: { status: 200, body: {} } }); + registry.register({ prefix: '/live', match: 'exact', methods: ['GET'], liveness: true, handler }); + registry.register({ prefix: '/plain', match: 'exact', methods: ['GET'], handler }); + + expect(registry.resolveLiveness('/live', 'GET')).toBeDefined(); + // Same matcher: an exact route does not claim a deeper path, and a + // method the route excludes reaches nothing at all. + expect(registry.resolveLiveness('/live/deep', 'GET')).toBeUndefined(); + expect(registry.resolveLiveness('/live', 'POST')).toBeUndefined(); + // A registered route with no declaration is not liveness. + expect(registry.resolveLiveness('/plain', 'GET')).toBeUndefined(); + }); + + it('a non-liveness route registered EARLIER shadows here exactly as it shadows in `resolve`', async () => { + // The set can never disagree with the matcher about which route a path + // reaches: `resolveLiveness` asks `resolve` first and only then reads + // the field, so it cannot certify a route the request would not get. + const registry = new DomainHandlerRegistry(); + const handler = async () => ({ handled: true, response: { status: 200, body: {} } }); + registry.register({ prefix: '/x', match: 'prefix', handler }); + registry.register({ prefix: '/x', match: 'exact', methods: ['GET'], liveness: true, handler }); + + expect(registry.resolve('/x', 'GET')?.liveness).toBeUndefined(); + expect(registry.resolveLiveness('/x', 'GET')).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 7b457ec0aa..019fc81145 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -652,8 +652,19 @@ export class HttpDispatcher { // that had nothing to do with the data layer. The dependency check // belongs on `/ready`, whose failure mode (leave the LB rotation) is // the one that actually helps. + // + // [#15910] `liveness: true` extends that promise from this handler's + // BODY to the whole REQUEST (maintainer ruling 2026-09-06, decision + // batch #57, option C — verbatim 「同意」). The handler checked nothing, + // but `dispatch()`'s identity step ran before it and read the tenancy + // posture, so after #15909 a `tenancy` service that was registered and + // failed to build answered an uncredentialed `GET /health` with 503 — + // the restart loop this comment already argues against, arriving + // through the preamble instead of through the body. The flag is read by + // `DomainHandlerRegistry.resolveLiveness`; see the carve-out at the head + // of `dispatch()` for why the set is derived and never listed. this.domainRegistry.register({ - prefix: '/health', match: 'exact', methods: ['GET'], + prefix: '/health', match: 'exact', methods: ['GET'], liveness: true, handler: async () => ({ handled: true, response: this.success({ @@ -2303,6 +2314,44 @@ export class HttpDispatcher { async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext, prefix?: string): Promise { let cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths + // ── Liveness carve-out — the ONE route family that runs no preamble ── + // [#15910, maintainer ruling 2026-09-06 (decision batch #57), option C, + // verbatim 「同意」] "Carve liveness out of the identity step. `/health` + // (liveness) answers 200 whenever the process can serve HTTP, regardless + // of configuration faults; `/ready` (readiness) keeps returning 503 for + // the identity/configuration fault so traffic is withheld until the + // fault is fixed. A configuration fault must never restart a pod that + // cannot be fixed by restarting." + // + // WHAT THIS IS NOT. It is deliberately NOT the fast path the anchored + // invariant below forbids: nothing here resolves a handler for a route + // that did not DECLARE itself a liveness probe, so no migrated domain is + // un-gated and no handler receives an unresolved context it wanted + // resolved. `/health`'s body reads process-local state only — it is the + // one handler in this table for which "already-scoped, already-gated" was + // never part of the contract. + // + // WHY THE SET IS DERIVED. `resolveLiveness` is `resolve` plus one field + // read, so "which routes are liveness" is answered by the live route + // table through the same matcher that picks the handler. ⛔ Never replace + // it with an array of liveness paths: a second list of routes drifts from + // the first one silently, and this repo has paid for that shape more than + // once. A future liveness route becomes liveness by declaring it at its + // own registration — the only edit that cannot be forgotten, because it + // is the same object that makes the route exist. + // + // WHY BEFORE THE SCOPED-URL STRIP. `cleanPath` is still environment- + // scoped here, so `/environments/:id/health` does NOT match and keeps + // today's behaviour end to end (it resolves its environment, runs both + // gates, and answers from the same handler — pinned in + // `http-dispatcher.scoped-url-strip.test.ts`). No orchestrator wires a + // scoped probe: the liveness surface is the unscoped `${prefix}/health` + // the dispatcher plugin mounts, and that is exactly the set carved out. + const livenessRoute = this.domainRegistry.resolveLiveness(cleanPath, method); + if (livenessRoute) { + return await livenessRoute.handler({ path: cleanPath, method, body, query }, context); + } + // ── Gates run BEFORE any domain body (ADR-0076 D11 step ③) ── // Scope resolution plus the two gates below are the dispatcher's half of // the D11 contract: a body extracted to `./domains/` receives an @@ -2312,7 +2361,9 @@ export class HttpDispatcher { // ordering is not overhead to optimize away: moving the domain-registry // resolve (further down) above these lines would un-gate every migrated // domain at once and hand handlers a context whose per-request kernel was - // never resolved (#5155). Anchored in scripts/adr-anchors/. + // never resolved (#5155). Anchored in scripts/adr-anchors/. The one + // exception is the DECLARED liveness carve-out above, which resolves no + // route that did not ask to run without a preamble (#15910). await this.resolveRequestScope(context, cleanPath); // ── ADR-0069 Authentication-policy gate ── diff --git a/scripts/adr-anchors/packages__runtime__src__http-dispatcher.ts.json b/scripts/adr-anchors/packages__runtime__src__http-dispatcher.ts.json index 6334b7edce..6919bef612 100644 --- a/scripts/adr-anchors/packages__runtime__src__http-dispatcher.ts.json +++ b/scripts/adr-anchors/packages__runtime__src__http-dispatcher.ts.json @@ -3,5 +3,5 @@ "adrs": [ "ADR-0076" ], - "invariant": "What is left of ADR-0076 D11's 'clean port with a god implementation' after step ③: a thin core (request scope, gates, discovery, registry seeding) whose domain bodies live under `./domains/` behind thin delegates. Two orderings inside `dispatch()` carry the decision and both read like removable overhead. (1) `resolveRequestScope` then the ADR-0069 auth gate then project-membership enforcement run BEFORE the domain registry is consulted — hoisting the registry resolve above them as a fast path for migrated domains un-gates every extracted domain at once and hands handlers a context whose per-request kernel was never resolved. The domains cannot compensate: they add their OWN authorization (`/ai`'s declared per-route `auth`, `/keys`' identity gate) but nothing downstream re-runs these two, which are the dispatcher's half of the D11 contract. (2) The registry is consulted before the legacy if-chain, which is now EMPTY of domains and must stay empty: a re-added `startsWith` branch either shadows a registered domain or re-implements a path another package already owns — the 'one route, one owner' hazard whose two specimens (`GET /openapi.json`, the `apis:` `handleApiEndpoint`) were DELETED rather than repaired (#5093, #4936) exactly because grep found them and the runtime never ran them. The service entries this file computes for discovery are D12's honesty rule (`svcAvailable` + `isServiceServeable`), never a slot-presence test." + "invariant": "What is left of ADR-0076 D11's 'clean port with a god implementation' after step ③: a thin core (request scope, gates, discovery, registry seeding) whose domain bodies live under `./domains/` behind thin delegates. Two orderings inside `dispatch()` carry the decision and both read like removable overhead. (1) `resolveRequestScope` then the ADR-0069 auth gate then project-membership enforcement run BEFORE the domain registry is consulted — hoisting the registry resolve above them as a fast path for migrated domains un-gates every extracted domain at once and hands handlers a context whose per-request kernel was never resolved. The domains cannot compensate: they add their OWN authorization (`/ai`'s declared per-route `auth`, `/keys`' identity gate) but nothing downstream re-runs these two, which are the dispatcher's half of the D11 contract. The ONE carve-out is a route that DECLARES `liveness: true` on its own registry entry (`/health` today): the maintainer ruling of 2026-09-06 (decision batch #57, option C) takes liveness out of the identity step entirely, because a configuration fault reaching the route whose consumer restarts the pod is a restart loop that hides the fault. That carve-out is narrow BY CONSTRUCTION and must stay so — it resolves nothing for a route that did not declare itself, and the liveness set is DERIVED from this same route table (`DomainHandlerRegistry.resolveLiveness` = `resolve` + one field read), never written down a second time as a list of paths. (2) The registry is consulted before the legacy if-chain, which is now EMPTY of domains and must stay empty: a re-added `startsWith` branch either shadows a registered domain or re-implements a path another package already owns — the 'one route, one owner' hazard whose two specimens (`GET /openapi.json`, the `apis:` `handleApiEndpoint`) were DELETED rather than repaired (#5093, #4936) exactly because grep found them and the runtime never ran them. The service entries this file computes for discovery are D12's honesty rule (`svcAvailable` + `isServiceServeable`), never a slot-presence test." }