fix(runtime): carve liveness out of the identity step so a configuration fault cannot restart a pod - #16561
Conversation
…lt cannot restart a pod `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5a497465412d73d76e11ebe7ebad5471991d7f3b && git checkout 5a497465412d73d76e11ebe7ebad5471991d7f3b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ce8caba91403c8f160cb7764c63b08371a13db99 e564d8bebf1e4f616be052fc5afb078541d70443 && git checkout -B drift-repro ce8caba91403c8f160cb7764c63b08371a13db99 && git merge --no-ff e564d8bebf1e4f616be052fc5afb078541d70443
node scripts/docs-audit/affected-docs.mjs --json ce8caba91403c8f160cb7764c63b08371a13db99 |
Fixes #15910
A liveness probe that answers 503 for a configuration fault tells the orchestrator to restart a pod whose fault no restart can fix. This carries out the maintainer's ruling on #15910 (comment 5559821649, director seat, decision batch #57, 2026-09-06, maintainer reply verbatim 「同意」), option C:
Option A (leave as shipped) and option B (make the posture read lazy for anonymous requests) are not taken; B was weighed and rejected in the ruling round for changing when a security-relevant value is read for every anonymous request.
The premise, measured on this branch — not inherited
PR #15909 is merged, so the behaviour is
main's today. This branch measured it rather than quoting it: with the carve-out ablated out ofdispatch()(one line, restored under a trap, byte-verified), the acceptance test reportsThat left-hand object is
origin/main's answer to two uncredentialed probes on one kernel whosetenancyservice is registered and fails to build./readyreally does distinguishrunningfrominitializing— confirmed, with the nuanceTriage flagged this as reproduced-by-nobody and inherit-at-your-risk. Read at source (
packages/runtime/src/http-dispatcher.ts, the/readydomain registration):with
KernelState = 'idle' | 'initializing' | 'running' | 'stopping' | 'stopped'(packages/core/src/kernel-base.ts). So the claim holds, and the precise shape is worth stating: the branch isrunningversus everything else, not aninitializing-specific arm — and the state that was read is echoed on the wire in the 503 body'sdetails.state, so an operator reading it seesinitializingtold apart fromstoppingandstopped. The suite measures that echo (details.stateisidleon an unstarted fixture kernel)./readyis therefore already the route meaning "this deployment is not serving correctly", and it is already wired.What changed
A route may now declare
liveness: trueon itsDomainRouteregistry entry.HttpDispatcher.dispatch()runs such a route's handler directly — noresolveRequestScope, no ADR-0069 auth gate, no project-membership gate, so nothing that reads configuration or credentials is evaluated./healthdeclares it; its payload (status,timestamp,version,uptime) was already process-local, which is the ruling's first execution note satisfied by construction rather than by promise.The identity step is the only thing
/healthloses. The two gates the carve-out also skips were already no-ops for this path, by measurement at source:enforceAuthGateopens withisAuthGateAllowlisted(cleanPath)andALLOW_SUFFIXESinpackages/core/src/security/auth-gate.tsnames/health;enforceProjectMembershipopens with askipPathslist that names it too. So skipping them is observationally identical, and the whole behavioural delta of this PR is the one thing the ruling asked for.Everything else is deliberately untouched:
/readykeeps the full identity step and its 503 body, so operator dashboards keep their signal (ruling's second execution note).GET /data/taskunder the same fault still leavesdispatch()as a 503SERVICE_UNAVAILABLE.POST /healthis not liveness (the route declaresmethods: ['GET'], and the carve-out inherits that)./environments/:id/healthkeeps today's behaviour end to end — the carve-out sits above the scoped-URL strip on purpose, and no orchestrator wires a scoped probe.How the liveness route set is DERIVED, not listed
The card raised this to a binding constraint, citing this repo's own drifted hand-maintained lists (#15915, #16169, #15584). There is no new list:
The liveness set is a projection of the live route table —
resolve()plus one field read — so it answers through the same matcher that picks the handler. It cannot name a route that is not registered, cannot miss one that is, and cannot disagree with the matcher about which route a path reaches (a non-liveness route registered earlier shadows here exactly as it shadows inresolve). A future liveness route becomes liveness by declaring it on the same object that makes the route exist, which is the only edit that cannot be forgotten.The suite pins the derivation adversarially: it registers a new liveness route through the public
registerDomainHandlerseam and drives it through the same fault. A hard-coded'/health'test insidedispatch()would fail that leg alone while every other leg stayed green.Docs (ruling's third execution note)
content/docs/deployment/self-hosting.mdx— the page that already carries the probe table and the reference Kubernetes manifest — gains the paragraph: which probe goes onlivenessProbe, which onreadinessProbe, never crossed, and what crossing them costs (CrashLoopBackOffin place of the fault).content/docs/deployment/cli.mdx'sos servesection gains a short note and a link to it rather than a second copy of the field mapping, so the mapping stays in one place.Tests
Run at
e564d8be(git rev-parse --short HEADof the commit under measurement).pnpm --filter @objectstack/runtime exec vitest runpnpm --filter @objectstack/runtime typecheck(tsc --noEmit+check:test-typecheck)pnpm --filter @objectstack/http-conformance exec vitest run@objectstack/runtimebuilt, so it is measured after that build)pnpm --filter @objectstack/hono exec vitest runpnpm --filter @objectstack/cli exec vitest run --project unitunittier; theintegrationtier spawns the CLI and is declared to CI, which runs both)eslint . --no-inline-config --format jsonNew:
packages/runtime/src/http-dispatcher.liveness-carve-out.test.ts— 12 tests, all passing. The acceptance test the ruling names asserts the pair in one expression, against one forced fault:Either half alone is non-discriminating:
/health200 alone is satisfied by deleting the re-raise outright,/ready503 alone is satisfied byorigin/main. Both probes are driven through the real mounted routes on a realObjectKernelwhosetenancyservice factory throws, so the rejection under test is the service registry's own unbranded one.The two 503s are told apart rather than assumed: under the fault
/readyanswers the identity step's declared-5xx envelope (message withheld, nodetails), while with a healthytenancyon the same wiring it answers its own verdict (Service not ready,details.state). Without that control the readiness half would prove nothing.Ablation, in the direction predicted: reverting the one carve-out lookup turns 4 of the 12 red (the pair, the payload, the dispatch-level leg, and the derived-set leg) and leaves 8 green — the 8 that describe unchanged behaviour. Mutation and restore were both proved on disk (occurrence counts before/after,
git hash-objectback to theHEADblob,git diff HEADempty), under atrap ... EXIT INT TERM. Nodistis involved: the suite imports./http-dispatcher.jsrelatively, which resolves to source — demonstrated by the fact that the newresolveLivenessleg passes on a tree where@objectstack/runtimewas never rebuilt, while the cross-packagehttp-conformancesuite could not resolve the package at all until it was.Gates
Derived from the tree at runtime, no paths passed:
node scripts/pm/dispatch-gates.mjs --commands(repo asserted with--repo objectstack-ai/objectstack), each command run byte-for-byte as printed and recorded as it ran. Reconciled against the tool's own count, not against arithmetic over my list:93 of the 94 pass. The one that does not is
PREREQUISITE NOT MET(exit 3) — nothing measured, neither a pass nor a finding, and it says so itself:check:dual-build-cjs-loadsreads built output for 38 packages that have nodistin this worktree. CI builds fresh and measures it.check:type-check-debtfirst reported the same exit 3 here, for a different reason — its--re-measureleg exhausted a 4 GB heap on a whole-treetscand refused to record a0. Re-run with--max-old-space-size=8192it passes:OK — 76/80 workspace packages type-checked, 4 in the DEBT ledger, with tsc itself still under the gate's own pinned 6144 MB CI-shaped ceiling. The first reading was an environment limit, not a ratchet movement.The separate artifact-roster block (36 families, 46 command spellings) was run on its own, as it sits beside the derived total rather than inside it: 43 pass, and the three that do not are wiring or prerequisite, not verdicts.
check:partof-closing-keywordprintsNOT WIREDwith no PR context and was then run wired — this body plus this PR's commit list — and passes.check:single-claim-pathsneeds a GitHub token to list the PR's files; retried against this PR number it getsGitHub API 401, so it stays NOT MEASURED here and CI measures it.check:react-declaration-parityhas no objectuisdui.manifest.jsonto compare against, which is by design: it is an on-demand gate triggered by an.objectui-shabump this PR does not make.Full per-command results are in the report comment on #15910.
Clause ②
no, re-derived from the delivered diff (the claim's declaration wasno; this is the re-derivation the dispatch required, not an inheritance):packages/spec/src/**. No*.zod.tscontract schema, noERROR_CODE_LEDGER/StandardErrorCodeentry, no new error code.DomainRoute.liveness,DomainHandlerRegistry.resolveLiveness) is API-surface widening on@objectstack/runtime, paid for by theminorchangeset — not a published contract schema.No
needs:contract-reviewlabel is applied.Base
Branched at
f48f3f1b2and not merged withorigin/main, deliberately and declared rather than skipped: everything above was measured on that base, and the commits that landed since (throughce8caba91) touch 17 files with zero overlap with this PR's 7, and none of them touches the dispatcher's identity step, the domain registry, or probe routing. AGENTS.md scopes the pre-PR re-run to incoming commits that touch the same packages or the same behaviour; these do not. The PR's own CI builds the merge ref, and the merge queue re-verifies on the rebuilt generation, so the landing seat's merge is the right place for it — merging here would only invalidate the readings above against amainthat moves every few minutes on this container.验收备注
Observed while reading, not filed and not fixed here — none is a reproducible defect, a violation of declared contract text, or a metadata-authoring trap:
/health:ALLOW_SUFFIXESinpackages/core/src/security/auth-gate.tsandskipPathsinHttpDispatcher.enforceProjectMembership. Both cover more than liveness (/auth,/cloud,/ready,/discovery) and are read from more than one door, so neither is subsumed by the derived liveness set and neither was touched. Worth knowing that the liveness carve-out now precedes both for/health.packages/runtime/src/route-ledger.tscarries a third place that callsGET /healtha liveness probe, in prose on its ledger row. It is ratcheted byroute-ledger.conformance.test.tsagainst the dispatcher's domains, so it is not an unwatched list; left as is.