From e564d8bebf1e4f616be052fc5afb078541d70443 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 09:05:29 +0000 Subject: [PATCH] fix(runtime): carve liveness out of the identity step so a config fault cannot restart a pod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HttpDispatcher.dispatch()` resolved a per-request identity before any route handler ran, and that step reads the tenancy posture for every request — credentialed or not. A `tenancy` service that is registered and fails to build is re-raised as 503 rather than absorbed into "there is no posture", so an uncredentialed liveness probe was answered 503 for the length of the outage: a liveness 503 means "restart me", the service fails to build again on the new pod, and the restart loop hides the fault the 503 exists to make loud. A route may now declare `liveness: true` on its registry entry. `dispatch()` runs such a route's handler directly — no identity resolution, no gate, nothing that reads configuration or credentials. `/health` declares it; its payload was already process-local. `/ready` is untouched and keeps the full identity step and its 503 body, so traffic is still withheld until the fault is fixed. Which routes count as liveness is DERIVED from the dispatcher's own route table: `DomainHandlerRegistry.resolveLiveness()` is `resolve()` plus one field read, so it answers through the same matcher that picks the handler and cannot drift from the routes that exist. There is no second list of paths. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- ...iveness-carved-out-of-the-identity-step.md | 13 + content/docs/deployment/cli.mdx | 9 + content/docs/deployment/self-hosting.mdx | 14 + .../runtime/src/domain-handler-registry.ts | 38 +++ ...http-dispatcher.liveness-carve-out.test.ts | 305 ++++++++++++++++++ packages/runtime/src/http-dispatcher.ts | 55 +++- ...ges__runtime__src__http-dispatcher.ts.json | 2 +- 7 files changed, 433 insertions(+), 3 deletions(-) create mode 100644 .changeset/liveness-carved-out-of-the-identity-step.md create mode 100644 packages/runtime/src/http-dispatcher.liveness-carve-out.test.ts 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." }