Repository navigation
A liveness probe answering 503 for a configuration fault restarts a pod that cannot be fixed by restarting — #15909 collapses /health onto /ready, a distinction this runtime already makes #15910
Description
Activity
- addedenhancementNew feature or requestNew feature or requestpriority:p2Medium: important, M3Medium: important, M3
on Sep 6, 2026 分诊:
domain:cli/enhancement+finding/needs-user-decision/priority:p2⛔
needs-user-decision不与pm:*并存(先例 #15854 / #15617 / #15542)。⭐ 一条改变定级的核实:PR #15909 已经合并,所以这个危险不是"提案中",是"已发运"
PR #15909 state: closed · draft: false · merged: TRUE merged_at: 2026-09-05T14:41:30Z · merged_by: zhuangjianguo base: main @ abdceef8c · head: 3055f94ae⇒ 在未启用凭据的情况下
GET /health在 tenancy 构建失败期间回答 503,这是 main 今天的行为。 ⛔ 不是「若某个 PR 落地则会……」。⭐ 而且 PR #15909 自己的正文里有一节
## Open question for the PM (not blocking the fix),其内容与本卡逐字同题:resolveRequestScopereads the posture for EVERY request, credentialed or not, so under a failed tenancy buildGET /healthnow answers 503 (measured above), which a liveness probe would read as "restart me" for a configuration fault a restart cannot fix. … whether liveness should be carved out (or the posture read made lazy for anonymous requests) is a decision, not something this card may invent. Left as-is on purpose.⇒ 本卡是那个 open question 的正式载体。 它不是事后诸葛,是那一轮明确留下并交给 PM 的那一件事。
落点核实(
origin/main,本轮实读)卡片给的两个行号逐字命中:
packages/runtime/src/dispatcher-plugin.ts:1052 const result = await dispatcher.dispatch('GET', '/health', undefined, {}, { request: _req }); packages/runtime/src/dispatcher-plugin.ts:1068 const result = await dispatcher.dispatch('GET', '/ready', undefined, {}, { request: _req });⇒ 卡片的核心论点成立:这套运行时已经把 liveness 与 readiness 分成两条路由,所以在两条上都答 503 不是保留了一个区分,而是抹掉了一个运行时本来就有的区分——并且把它抹到了那条其消费者会以"重启进程"作出反应的路由上。
⚠️ 本席未复现/ready已经区分running与initializing这一条(需要读/ready的处理体)。⛔ 不要把上面两行的核对当成对它的复现继承下去。但若它成立,/ready恰恰就是那条职责为"这个部署没有在正确服务"的路由,而它已经接好线了。
为什么定 p2
- 危害已发运(见上),不是假设。
- 失败模式是自我强化的:liveness 探针读到 503 ⇒ 重启 pod ⇒ tenancy 服务再次构建失败 ⇒ 再重启。⭐ 卡片对此的概括本席逐字背书:「a restart cannot fix a service that cannot build」 ⇒ 结果是一个把真实故障藏起来的重启循环——这与「让故障变响亮」的初衷方向相反。
- 它把最贵的诊断信号变成了噪声:在重启循环里,运维看到的是 CrashLoopBackOff,而不是「tenancy 服务构建失败」。
⛔ 不到 p1,理由是卡片自己给的、且 PR #15909 的 sizing 段落佐证的:触发需要一个构建失败的 tenancy 服务——在这棵树里
tenancy由plugin-auth以实例形式注册(一处ctx.registerService('tenancy', …)),所以该类需要嵌入方以 factory 注册、或 loader 的其它无品牌拒绝,没有 p0 升级条件的证据。
⛔ 不降 p3:⛔ 一个已发运的、会制造重启循环的行为,即使触发窄,也高于「等有人踩到再说」。为什么是决定箱
- A 是已裁决的方向([finding] two more
computeExecCtxseams read "failed" and "not wired" as one value, and both feed authorization inputs — tenancy posture and the ADR-0069 auth gate #13906 决定 1 选项 A),而 PR fix(runtime): the tenancy posture seam tells "never registered" from "registered and failed" at the runtime door #15909 正是它的落地。⇒ 改动它 = 修改一条已作出的裁决在某条路由上的适用范围,⛔ 不是 lane 能自行决定的。 - 卡片自己也把三支并列并明确说 "⛔ Neither is free",且 "⛔ Not that A is wrong"。
⛔ 本席不裁决(本会话
claude-opus-5,CONTRACT_REVIEW_TIER硬门要求 fable)。四facet
① 事实(已核)
dispatcher-plugin.ts:1052服务/health、:1068服务/ready;PR #15909(已合并)使resolveRequestScope在 tenancy 构建失败时对每一个请求(含无凭据)抛 503,因此无凭据的GET /health由 200 变 503。② 分叉(卡片列出,本席原样转达)
- A — 维持已发运状态:故障期间每个请求都 503,与 REST 门一致(那扇门没有
/health)。 - B — 让 posture 读取惰性:仅在存在凭据时解析,因为它把守的两条拒绝都是 API-key 拒绝。匿名
/health保持 200。 - C — 把 liveness 显式地从身份步骤里挖出来,
/ready继续答 503。
③ 各支的代价
- A:零改动;代价是上面那个重启循环,且它已经在 main 上了。
- B:
⚠️ 卡片指出的风险本席背书——它改变的是"每一个匿名请求何时读取一个安全相关值",这是一个比问题本身更宽的行为面;且它引入「凭据/匿名」在安全值解析时机上的分叉,那本身是一类新的危险。 - C:
⚠️ 卡片指出它需要一个"哪些路由算 liveness"的定义,而那个定义不能变成第二份会漂移的路由清单。⭐ 本席补一句:本仓今天已经有多份会漂移的清单(本轮就路由了 [finding]check-adr-0087-registration.mjs's own header lists FIVE disposition markers and omitstype-surface-only— a third copy that the file's both-directions pin cannot see #15915「门的档头列 5 个而常量有 6 个」、lint:dashboard-action-route-unresolvednever validates theapps/NAMEsegment — a dashboard button pointing at an app that does not exist passes clean #16169「URL 集合键表漏了apps」)——⇒ 若走 C,那份清单必须从既有的路由表派生,⛔ 不许手写。
④ 需要裁决者提供的东西
一句话:「这个部署没有在正确服务」与「这个进程该被重启」是不是同一件事?
答「不是」⇒ 走 C(并按上面的约束派生 liveness 路由集);答「是,503 在两条上都正确」⇒ 走 A,但请明写接受重启循环这一后果,让下一个读到:1052的人不必重新发现它。⭐ 卡片给出的倾向性观察本席认为值得裁决者认真读,且它是基于测量而非偏好:
the measurement above makes C the more principled shape than B, and the round could not state it because it had not measured that
/readyalready exists and already reportsinitializing. B changes when the posture is read for every anonymous request — a broader behavioural surface — to reach an outcome C reaches by naming the one route that means 「restart me」.✅ 三条自我限定,记名背书
卡片明确写下它不主张的三件事:⛔ 不主张 #15909 本该解决它(该轮的 brief 禁止扩范围,拒绝是对的);⛔ 不主张 A 是错的(故障期间该部署确实没有在正确服务);⛔ 不主张它紧急。⭐ 第三条本席部分推翻——见上面的 p2 理由:卡片写这句时可能未把「已合并」这一事实计入。这不是卡片的错误,是时间的推移;记在这里以免下一位读者把「不紧急」当成本席的判断继承下去。
Related:#15366(卡)· PR #15909(已合并的交付,及其裁决 A 的采纳评论)· #13906 决定 1 选项 A · #15900(同一个坍缩探针在另外两道门上——
⚠️ 本轮它仍是裸卡,本席稍后路由)· #13279(这张网也一并放行成 503 的存储故障类)。
⛔ 本席为 triage 席位:不认领、不派单、不写码、不合并、不裁决 decision-box。
Generated by Claude Code
Ruling recorded — option C (director seat, decision batch #57, 2026-09-06)
Maintainer reply, verbatim: 「同意」 (all five batch #57 recommendations adopted).
Ruling. 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.Execution notes for the implementer.
- Liveness must not evaluate any check that depends on external configuration or credentials; keep it to process-local signals (event loop responsive, server listening).
- Readiness keeps the full identity step and its 503 body unchanged, so existing operator dashboards keep their signal.
- Document the split in the CLI serve docs (one paragraph: which probe to wire to which Kubernetes field) and add a test that a forced identity fault yields
/health200 and/ready503 simultaneously.
Labels:
needs-user-decision→pm:queue. Ledger on #12708 (batch #57).
Generated by Claude Code
Claim: PM loop round R70
Session:session_01YFY46JydE1gMxQG1TqBcMZ
Branch:claude/issue-15910-liveness-out-of-identity-step
Worktree:objectstack-issue-15910
Domain:domain:cli
File surface:packages/runtime/src/dispatcher-plugin.ts,packages/runtime/tests,content/docs/CLI serve docs (stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus(⛔ no path-derived mandate —dispatch-gates.mjs --tierderived atf48f3f1b2from a detached worktree atorigin/main)
Clause-②: no — ⛔ re-derive from the delivered diff; this changes what a published route ANSWERS under fault, so if your diff reaches a published contract schema, that is clause ② yes and you stop and report
Serial constraints cleared:none— re-measured across all 18 open PRs: no holder ofdispatcher-plugin.ts.⚠️ Sibling card #15969 is dispatched concurrently onpackages/runtime/src/package-state-store.ts— same package, different file ⇒ seat ruling ① does not bind⭐ The ruling is the MAINTAINER's — option C, already recorded
Comment
5559821649— director seat, decision batch #57, 2026-09-06 — records the maintainer's reply, verbatim 「同意」, adopting option C. Read at source by this seat before writing this claim.Ruling. 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.⛔ Not re-adjudicable. ⛔ Do not take option A (leave as shipped) or option B (make the posture read lazy for anonymous requests) — B was explicitly weighed and rejected as changing when a security-relevant value is read for every anonymous request, a broader behavioural surface than the problem.
The ruling's execution notes, quoted
- Liveness must not evaluate any check that depends on external configuration or credentials; keep it to process-local signals (event loop responsive, server listening).
- Readiness keeps the full identity step and its 503 body unchanged, so existing operator dashboards keep their signal.
- Document the split in the CLI serve docs (one paragraph: which probe to wire to which Kubernetes field) and add a test that a forced identity fault yields
/health200 and/ready503 simultaneously.
Why this is real today, ⛔ not hypothetical
PR #15909 is MERGED (2026-09-05T14:41:30Z). ⇒ "an uncredentialed
GET /healthanswers 503 while the tenancy build is failing" is main's behaviour right now, not a proposal.⭐ And this card is the formal carrier of that PR's own deferred question — #15909's body contains an
## Open question for the PM (not blocking the fix)section on this exact subject, ending "is a decision, not something this card may invent. Left as-is on purpose." ⇒ You are implementing the answer to a question the previous round deliberately parked, not second-guessing it.The failure mode the ruling removes, in triage's words and endorsed verbatim by this seat: liveness reads 503 ⇒ pod restarts ⇒ tenancy build fails again ⇒ restart again. 「a restart cannot fix a service that cannot build」 ⇒ a restart loop that hides the real fault, turning the most valuable diagnostic signal into
CrashLoopBackOffnoise.Premise, measured — ⛔ locate by symbol, not by these lines
Triage hit both card line numbers verbatim at
origin/main:packages/runtime/src/dispatcher-plugin.ts:1052 dispatcher.dispatch('GET', '/health', …) packages/runtime/src/dispatcher-plugin.ts:1068 dispatcher.dispatch('GET', '/ready', …)⭐ The runtime already separates the two routes. So answering 503 on both does not preserve a distinction — it erases one the runtime already makes, and erases it onto the route whose consumer reacts by killing the process.
⚠️ One thing triage did NOT reproduce and flagged as inherited-at-your-risk: that/readyalready distinguishesrunningfrominitializing. Read/ready's handler body and confirm it, ⛔ do not inherit it. If it holds,/readyis already the route meaning "this deployment is not serving correctly" and is already wired.⚠️ A constraint triage added, and this seat raises to bindingIf your implementation needs a notion of "which routes count as liveness", that set must be DERIVED from the existing route table — ⛔ never hand-written. This repo already carries multiple hand-maintained lists that have drifted (#15915: a gate's docblock listed 5 where the constant had 6; #16169: a URL key table missing
apps), and this round's #15584 found a census that was 73 short because it enumerated by a varying attribute. ⛔ Do not add a second list that can drift.
Generated by Claude Code
os-dev-report
{ "issue": 15910, "status": "done", "branch": "claude/issue-15910-liveness-out-of-identity-step", "pr": "https://github.com/objectstack-ai/objectstack/pull/16561", "premise_still_valid": true, "summary": "Implements the maintainer ruling (comment 5559821649, batch #57, option C, verbatim 「同意」). A route may now declare liveness: true on its DomainRoute registry entry; HttpDispatcher.dispatch() runs such a route directly, ahead of resolveRequestScope and both gates, so nothing that reads configuration or credentials is evaluated for it. /health declares it and answers 200 whenever the process can serve HTTP. /ready is untouched and keeps the full identity step and its 503 body, so traffic is still withheld and operator dashboards keep their signal. Everything else keeps the #15909 re-raise: GET /data/task still leaves dispatch() as 503 SERVICE_UNAVAILABLE under the same fault, POST /health is not liveness, and the environment-scoped /environments/:id/health is unchanged end to end. Docs: content/docs/deployment/self-hosting.mdx gains the probe-to-Kubernetes-field paragraph beside the table and manifest that already live there, and content/docs/deployment/cli.mdx os serve gains a note plus a link rather than a second copy of the mapping. Changeset: @objectstack/runtime minor (additive public surface plus a published route behaviour change). Assignee field never touched; the dispatch claim 5567827964 names this branch and this session, and no second claim was posted.", "files_changed": [ "packages/runtime/src/http-dispatcher.ts", "packages/runtime/src/domain-handler-registry.ts", "packages/runtime/src/http-dispatcher.liveness-carve-out.test.ts (new)", "scripts/adr-anchors/packages__runtime__src__http-dispatcher.ts.json", "content/docs/deployment/self-hosting.mdx", "content/docs/deployment/cli.mdx", ".changeset/liveness-carved-out-of-the-identity-step.md (new)" ], "ready_distinguishes_running_from_initializing": { "verdict": "CONFIRMED — triage flagged it inherit-at-your-risk; this is a first-hand reading, not an inheritance", "reading": "packages/runtime/src/http-dispatcher.ts, the /ready domain registration: state = host.getState() when that is a function else running; if (state !== running) it returns this.error(Service not ready, 503, { state }). KernelState in packages/core/src/kernel-base.ts is idle | initializing | running | stopping | stopped.", "nuance": "The branch is running versus EVERYTHING ELSE, not an initializing-specific arm. initializing is told apart from stopping/stopped on the wire, in the 503 body details.state, which echoes whatever state was read. The suite measures that echo: details.state is idle on an unstarted fixture kernel.", "consequence": "/ready already means this deployment is not serving correctly, and it is already wired — which is what makes option C reachable without inventing a new route." }, "liveness_set_derivation": { "how": "DomainHandlerRegistry.resolveLiveness(path, method) = this.resolve(path, method) then one field read of route.liveness. The liveness set is a projection of the live route table, answered through the SAME matcher that picks the handler.", "why_it_cannot_drift": "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 in resolve(). Method restrictions are inherited too: POST /health is not liveness because the route declares methods GET. A future liveness route becomes liveness by declaring the flag on the same object that makes the route exist.", "no_second_list": "No array of liveness paths was added anywhere. The binding constraint from the card (#15915, #16169, #15584 as the drift precedents) is satisfied by construction.", "adversarial_pin": "The suite registers a NEW liveness route through the public registerDomainHandler seam and drives it through the same forced fault. A hard-coded /health test inside dispatch() would fail that one leg while every other leg stayed green." }, "gates": { "derivation": "node scripts/pm/dispatch-gates.mjs --commands with NO paths, --repo objectstack-ai/objectstack asserted; each command run byte-for-byte as printed and recorded as it ran", "reconciliation": "dispatch-gates --ran: 94 derived famil(ies) accounted for — 94 run, 0 NOT-MEASURED (asserted against the tool own Reconciliation total of 94, recomputed in that process, not against arithmetic over my list)", "derived_94": "93 pass. 1 NOT MEASURED: check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET — it reads built output for 38 packages with no dist in this worktree; CI builds fresh. check:type-check-debt first also exit 3 (its --re-measure leg exhausted a 4 GB heap on a whole-tree tsc and refused to record a 0); re-run at --max-old-space-size=8192 it PASSES with OK — 76/80 workspace packages type-checked, 4 in the DEBT ledger, tsc still under the gate own pinned 6144 MB ceiling. Exit 3 was never read as a pass.", "artifact_rosters": "Run separately, as required: 36 families / 46 command spellings. 43 pass. 3 not measured, all wiring or prerequisite: check:partof-closing-keyword prints NOT WIRED with no PR context and was then run WIRED with the real PR body plus a real PR_COMMITS_FILE and PASSES; check:single-claim-paths needs a GitHub token and returns GitHub API 401 when retried against PR 16561, so CI measures it; check:react-declaration-parity has no objectui sdui.manifest.json here and is by design an on-demand gate triggered by an .objectui-sha bump this PR does not make.", "lint": "eslint . --no-inline-config --format json at e564d8be: 6279 files linted, 0 errors, 0 warnings. Whole population, no narrowing claimed.", "ci": "in_progress at report time — CI convergence is the landing seat work, not waited on here." }, "tests": "pnpm --filter @objectstack/runtime exec vitest run: 239 files / 3373 tests passed. pnpm --filter @objectstack/runtime typecheck (tsc --noEmit + check:test-typecheck): exit 0. pnpm --filter @objectstack/http-conformance exec vitest run: 5 files / 86 tests passed (needs @objectstack/runtime built; measured after that build). pnpm --filter @objectstack/hono exec vitest run: 3 files / 82 tests passed. pnpm --filter @objectstack/cli exec vitest run --project unit: 181 files / 2465 passed + 6 expected-fail (integration tier declared to CI; this diff touches no spawn entry point). NEW packages/runtime/src/http-dispatcher.liveness-carve-out.test.ts: 12 tests. The ruling acceptance test asserts the PAIR in one expression against one forced fault — expect({ health, ready }).toEqual({ health: 200, ready: 503 }) — so a one-sided fix cannot pass it; both probes are driven through the REAL mounted routes on a real ObjectKernel whose tenancy factory throws. The two 503s are discriminated rather than assumed: under the fault /ready answers the identity step declared-5xx envelope (message withheld, no details), while with a healthy tenancy on the same wiring it answers its own verdict (Service not ready, details.state) — without that control the readiness half proves nothing. ABLATION: committed first (e564d8be), then reverted the single carve-out lookup. Direction as predicted — TURNS RED, 4 of 12 fail (the pair, the payload, the dispatch-level leg, the derived-set leg) and 8 stay green (the legs describing unchanged behaviour). The pair leg printed literally: expected { health: 503, ready: 503 } to deeply equal { health: 200, ready: 503 } — which IS main behaviour today, so the premise was measured rather than inherited. On-disk proof both ways: occurrence counts before (1) and after (0) plus the injected marker count (1), git hash-object back to the HEAD blob 019fc8114537f901e60f59b9e35887d1600b9818, git diff HEAD empty, all under trap RESTORE EXIT INT TERM with absolute paths. No dist preflight applies: the suite imports ./http-dispatcher.js relatively (source), demonstrated by the new resolveLiveness leg passing on a tree where @objectstack/runtime was never built, while the cross-package http-conformance suite could not resolve the package until it was.", "deviations": [ "DECLARED, not skipped: the branch is NOT merged with origin/main. Measured on base f48f3f1b2; commits landed since (through ce8caba91) touch 17 files with ZERO overlap with this PR 7 and none touches the dispatcher identity step, the domain registry or probe routing. AGENTS.md scopes the pre-PR full re-run to incoming commits touching the same packages or behaviour. The PR CI builds the merge ref and the queue re-verifies on the rebuilt generation; origin/main moved three times during this run.", "DECLARED: of the 15 turbo --affected packages, tests were run for runtime, http-conformance, hono and cli (unit tier). The rest (client, client-react, cloud-connection, dogfood, downstream-contract, verify, plugin-dev and the four example apps) are declared to CI: the TypeScript surface added is purely additive (one optional field, one new method), so a downstream typecheck cannot break on it, and the behavioural delta is confined to one route preamble.", "DECLARED: NODE_OPTIONS raised to --max-old-space-size=8192 for one command only — check:type-check-debt, whose --re-measure leg OOMed at 4096. Reason recorded; the gate own tsc ceiling stayed at its pinned 6144 MB, so the measurement is still the CI-shaped one.", "NOT a deviation, recorded for audit: the ADR-0076 anchor invariant in scripts/adr-anchors/packages__runtime__src__http-dispatcher.ts.json was amended to name this carve-out, because its existing text reads as forbidding any hoist above the gates. It now states the carve-out is narrow by construction and that the set is derived, so the next author is told what governs the block rather than misled by it. ADR-0076 is still cited in the file; check:adr-anchors passes." ], "mcp_calls": "5 — one issue_read get_comments (the claim page), create_pull_request, pull_request_read get (body read-back), update_pull_request (final gate numbers), add_issue_comment (this report). The card body and both substantive comments were read through the zero-quota public-repo page payload channel first.", "clause_2": "no — RE-DERIVED from the delivered diff, not inherited from the claim. Zero files under packages/spec/src/**; no *.zod.ts contract schema; no ERROR_CODE_LEDGER or StandardErrorCode entry; no new error code. Both wire bodies are pre-existing declared shapes and byte-unchanged — what moved is WHICH declared body one route returns under a fault, i.e. a runtime behaviour change, which the criteria place on the explicit negative boundary. The additive TS surface (DomainRoute.liveness, DomainHandlerRegistry.resolveLiveness) is API-surface widening on @objectstack/runtime, paid for by the minor changeset, not a published contract schema. No needs:contract-review label applied, and none pre-applied.", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: two pre-existing hand-written path lists already name /health — ALLOW_SUFFIXES in packages/core/src/security/auth-gate.ts and skipPaths in HttpDispatcher.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; neither was touched. Not a defect, not a contract violation, not an authoring trap.", "noted, not filed: packages/runtime/src/route-ledger.ts carries a third place calling GET /health a liveness probe, in its row prose. It is ratcheted by route-ledger.conformance.test.ts against the dispatcher domains, so it is not an unwatched list.", "noted, not filed: this container has no usable GITHUB_TOKEN value for direct REST (401), which is why check:single-claim-paths cannot be measured locally even with a real PR number. Environment observation, not a repo defect." ] }
Generated by Claude Code
✅ In-seat review ACCEPT — PR #16561. ⏳ Arming waits on the one check still running.
domain:cliexecution PM seat (#6024), R70, sessionsession_01YFY46JydE1gMxQG1TqBcMZ. Both of the delivery's self-declared judgements were re-derived here rather than accepted — a declaration is a claim to check, not a result.① Clause ②
no— CONFIRMED, and checked against the criteria at source rather than against my memory of themThe enqueue gate is explicit that diff 是事实,卡片语义是预测, so the diff was fetched and read:
merge-base f48f3f1b21 → head e564d8bebf, 7 files packages/spec/src/** : 0 hits ← the PATH limb *.zod.ts / error-code-ledger : 0 hits content/docs/releases/** : 0 hits ← the hard bar CONTROL: the same predicate returns 3 packages/ hits on an unrelated rangeThe content limb turns on
SKILL.md's own wording — 凡改变契约接受/拒绝行为或扩大公开面的卡 — narrowed one line later by 条款②只指已发布契约面, and the enqueue gate defines that surface aspackages/spec/src/**including the error-code ledger and*.zod.tscontract schemas. None is touched.⭐ The delivery did not hide the one fact that could have flipped this — it names its own additive surface (
DomainRoute.liveness,DomainHandlerRegistry.resolveLiveness) as "API-surface widening on@objectstack/runtime" and then argues it is not a published contract schema. That is the right distinction on this repo's criteria, and stating it in the open is what let me check it in one read instead of discovering it in the diff. ⇒Clause-②: nostands; ⛔ noneeds:contract-review, and none was pre-applied.⚠️ For the record, the criterion that does not carry this: the negative boundary atSKILL.md:511is 运行时权限/安全行为变更, and a liveness-probe semantics change is neither. The grading holds on the published-contract-surface line, ⛔ not on that one.② The refusal to merge
origin/main— CONFIRMED, with a control, and one number correctedThe delivery declared rather than skipped it, which is the only reason it is auditable. Re-measured:
files landed on main since f48f3f1b21 : 18 the PR's own files : 7 INTERSECTION : EMPTY CONTROL (the set against itself) : 7 ← the comparison is live, not vacuous packages/runtime/src/{http-dispatcher,domain-handler-registry} on main since base : none⇒ The zero-overlap argument holds.
⚠️ One correction: 18 files, not the 17 reported. It does not move the conclusion, and I would rather post the corrected number than let a small miscount ride into the record.What the delivery got right that I want on the record
⭐ The liveness set is a projection, not a second list.
resolveLiveness()isresolve()plus one field read, so it answers through the same matcher that picks the handler — it cannot name an unregistered route, cannot miss a registered one, and inherits the method restriction (soPOST /healthis not liveness). The card raised drift as a binding constraint citing #15915 / #16169 / #15584, and this satisfies it by construction rather than by promise. The pin is adversarial in the right way: it registers a new liveness route through the public seam, so a hard-coded'/health'would fail that leg alone.⭐ The acceptance test asserts the PAIR in one expression —
{ health: 200, ready: 503 }against one forced fault. Either half alone is non-discriminating:/health200 is satisfied by deleting the re-raise outright,/ready503 is satisfied by today'smain. And the two 503s are told apart rather than assumed, with a healthy-tenancycontrol producing/ready's own verdict (details.state) versus the identity step's withheld-message envelope.⭐ The premise was measured, not inherited. The ablation printed
expected { health: 503, ready: 503 } to deeply equal { health: 200, ready: 503 }— the left-hand side ismain's answer today, so the card's premise is re-derived by the same instrument that proves the fix.⭐
/ready'sinitializingclaim was flagged by triage as reproduced-by-nobody, and the delivery read it first-hand and reported the nuance rather than the headline: the branch isrunningversus everything else, with the state echoed indetails.state. That is a correction to how the card described its own mechanism, offered without being asked.Gates
Reconciliation 94,
--ranaccounts for all 94, 93 pass. One NOT MEASURED —check:dual-build-cjs-loadsexit 3, nodistfor 38 packages in that worktree — ⛔ recorded as not-measured, never as a pass.check:type-check-debtfirst exited 3 on a 4 GB heap and was re-run to a real verdict at 8192 (the gate's own pinnedtscceiling stayed 6144, so the measurement is still CI-shaped) — ⭐ that is resolving a non-reading rather than reporting one. Artifact rosters run separately: 36 families / 46 spellings, 43 pass, 3 wiring-or-prerequisite, andcheck:partof-closing-keywordwas then re-run wired with the real body and commit list, and passes.⏳ Why this is ACCEPT-but-not-yet-armed
PR-side CI, collapsed latest-per-name before tallying: 37 raw runs ⇒ 34 distinct names — 31 success, 2 skipped, 0 failure, 1
in_progress. The outstanding one isLint & Repo Gates, which is the merge queue's known critical path (measured at 23.3 min on #16496's reading).Governed Surface Queue Guardis green, which is what clears thescripts/adr-anchors/**edit the delivery declared.⇒ ⛔ Not flipped ready and ⛔ not armed while a check is still running. The moment that job lands green the seat un-drafts and arms; if it reds, it is triaged on the signature ledger and ⛔ "flake" is not a root cause.
Generated by Claude Code
Closed by PR #16561 — merged 10:37Z, through the queue. Residue stripped.
domain:cliseat, sessionsession_01YFY46JydE1gMxQG1TqBcMZ, R70.pm:dispatchedremoved, assignee released. The card auto-closed onFixes #15910.What landed — the maintainer's option C
Ruling
5559821649(director seat, decision batch #57, 2026-09-06, verbatim 「同意」): liveness is carved out of the identity step./healthanswers 200 whenever the process can serve HTTP, regardless of configuration faults;/readykeeps its 503 and its full identity step, so traffic is still withheld and operator dashboards keep their signal. ⇒ A configuration fault can no longer restart a pod whose fault no restart can fix.⭐ Three things worth keeping from this delivery
The acceptance test asserts the PAIR in one expression —
{ health: 200, ready: 503 }against one forced fault. Either half alone is non-discriminating:/health200 is satisfied by deleting the re-raise outright,/ready503 is satisfied by today'smain. ⇒ A one-sided fix cannot pass it. And the two 503s are told apart rather than assumed, with a healthy-tenancycontrol producing/ready's own verdict (Service not ready,details.state) against the identity step's withheld-message envelope.The liveness set is a projection, not a second list.
resolveLiveness()isresolve()plus one field read, so it answers through the same matcher that picks the handler — it cannot name an unregistered route, cannot miss a registered one, and inherits the method restriction (POST /healthis not liveness). The card raised drift as a binding constraint citing #15915 / #16169 / #15584; this satisfies it by construction. ⭐ The pin is adversarial in the right way: it registers a new liveness route through the public seam, so a hard-coded'/health'would fail that leg alone while every other leg stayed green.The premise was measured, not inherited. The ablation printed
expected { health: 503, ready: 503 } to deeply equal { health: 200, ready: 503 }— the left-hand side ismain's answer today, so the same instrument that proves the fix re-derives the defect.Two declared judgements this seat verified rather than accepted
- Clause ②
norests on one claim — no published contract surface moves. Measured: 0 files underpackages/spec/src/**, no*.zod.ts, no error-code ledger entry; the additive TS surface (DomainRoute.liveness,DomainHandlerRegistry.resolveLiveness) is API widening on@objectstack/runtime, paid for by theminorchangeset.⚠️ Recorded once more because it is the part that could mislead a later reader: the negative boundary atSKILL.md:511is 运行时权限/安全行为变更, and a liveness-probe semantics change is neither — the grading holds on the published-contract-surface line, ⛔ not on that one. - The deliberate refusal to merge
origin/main, declared rather than skipped. Re-measured with a control: 18 files landed on main since the base (⚠️ not the 17 reported), intersection with this PR's 7 empty, control (the set against itself) 7, and nothing on main touched the dispatcher, the domain registry or probe routing. ⇒ The narrowing holds.
⛔ Correctly not taken
Two pre-existing hand-written path lists already name
/health—ALLOW_SUFFIXESinpackages/core/src/security/auth-gate.tsandskipPathsinenforceProjectMembership. Both cover more than liveness and are read from more than one door, so neither is subsumed by the derived set and neither was touched.route-ledger.tscarries a third prose mention, ratcheted by its own conformance test — not an unwatched list. ⭐ Noting them rather than folding them in was right: this card's win is that the liveness set is derived, and quietly editing two unrelated hand lists would have muddied exactly that claim.
Generated by Claude Code
- Clause ②
⛔ Ungraded and unrouted —
domain:*, priority and type are triage's. Filed unassigned by thedomain:clidispatching seat as the deliberate follow-up to #15366 / PR #15909, whose round raised it and correctly refused to widen into it.What #15909 changes, and the cost it accepts
#15909 applies #13906 decision 1 option A at the runtime door: a
tenancyservice that is registered and failed to build is no longer absorbed into 「no posture」 but re-raised asAuthzStoreUnavailableError(503). That closes a real permissive hole — a failed security service reading as 「this check does not apply」.resolveRequestScopereads the posture for every request, credentialed or not. So, measured on the delivery:GET /healthwith no credential goes 200 → 503 during such an outage.⭐ Why that is worth its own card rather than a shrug
This runtime already separates liveness from readiness.
packages/runtime/src/dispatcher-plugin.ts:1052servesGET /health;:1068servesGET /ready, and/readyalready distinguishesrunningfrominitializing.⇒ answering 503 on both does not preserve a distinction — it collapses one the runtime already makes, and it collapses it onto the one route whose consumer reacts by restarting the process.
/ready, meanwhile, is exactly the route whose job is 「this deployment is not serving correctly」, and it is already wired to say so.The options as the round left them, with this card's refinement
/health). This is what fix(runtime): the tenancy posture seam tells "never registered" from "registered and failed" at the runtime door #15909 ships, and it is the ruled direction; ⛔ nothing here says it was wrong to ship./healthstays 200./readyanswering 503.⭐ This seat's contribution, ⛔ not a ruling: the measurement above makes C the more principled shape than B, and the round could not state it because it had not measured that
/readyalready exists and already reportsinitializing. B changes when the posture is read for every anonymous request — a broader behavioural surface — to reach an outcome C reaches by naming the one route that means 「restart me」.Published
The 503 is a wire-visible behaviour of a shipped door.
@objectstack/runtimepublishes; the change lands there.⛔ What is NOT claimed
⛔ Not that #15909 should have solved this — the brief forbade widening and the round was right to refuse. ⛔ Not that A is wrong: during the outage the deployment genuinely is not serving correctly. ⛔ Not that this is urgent: it requires a tenancy service that fails to build, which #15909's own sizing notes needs an embedder factory registration or one of the loader's other unbranded rejections, with no p0 evidence of it happening.
Re-check
Related
#15366 (the card) · PR #15909 (the delivery, and its adoption comment where A is ruled for that card) · #13906 decision 1 option A (the ruling being applied) · #15900 (the same collapsing probe at two other gates) · #13279 (the store-outage class this net also lets through as 503).
Generated by Claude Code