From 0618944ccfa83c5e3400f78bb26f566ce60aac0c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 12:37:44 +0000 Subject: [PATCH] =?UTF-8?q?docs(runtime):=20ADR-0076=20D11=20=E5=9B=9B?= =?UTF-8?q?=E6=9D=A1=E5=9C=A8=E5=9C=BA=E9=94=9A=E7=82=B9=E5=85=A5=E8=B4=A6?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20registry=20+=20dispatcher=20=E9=97=A8?= =?UTF-8?q?=E5=BA=8F=20+=20=E4=B8=A4=E4=B8=AA=E4=BB=A3=E8=A1=A8=E5=9F=9F?= =?UTF-8?q?=20(#5357)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D11 的产物是一整片实打实的代码(14 个域模块 + `DomainHandlerRegistry` + 收缩后的 dispatcher),而 `scripts/adr-anchors.json` 里对 ADR-0076 零命中 —— 被治理的文件 自己不提它所遵从的决定,作者就无从知晓(#3723 的机制)。 按 `check-adr-anchors.mjs` 自己的纪律选点("a map of everything is a map of nothing,每条锚点必须挣得它的失败模式"),不锚全部 14 个域,只锚四处「单看文件会 觉得可以『优化』掉、而改了会静默逆转 D11」的落点,每处的失败模式互不重复: 1. `domain-handler-registry.ts` —— 端口本体:规范化 handler 而非框架特定路由 (从插件空间注册 Hono `app.route` 会让每个插件耦合 Hono,葬掉 http-conformance 验证的多适配器性质);无通配/无参数/无中间件是刻意的;注册权留在 dispatcher, 因为多数槽位是多提供方(i18n、analytics),搬进某一个提供方会 404 掉其它栈; `DomainHandlerDeps` 的每个读内核设施都先收请求(#5155 的跨租户读)。 2. `http-dispatcher.ts` —— `dispatch()` 里两处「像是可以省掉的开销」的次序: scope 解析 + ADR-0069 认证门 + 成员门跑在域注册表**之前**;注册表跑在 if 链 之前,而 if 链现在对域是空的、必须保持空的(one route, one owner)。 3. `domains/data.ts` —— 代表域之一,D11 的收尾一刀,也正是 ADR 点名的 god implementation 核心 `handleData`;失败模式是把域体折回 dispatcher 的薄委托。 4. `domains/i18n.ts` —— 代表域之二,注册权归属规则的具体实例:该槽位由 service-i18n 或 AppPlugin 内存兜底二者之一填充,把 `/i18n` 注册搬进 service-i18n 这个「看起来天然正确」的清理,会 404 掉另一提供方服务的每个栈。 失败文案携带不变量(违反了什么决定、为什么不能这么改),不是只写 ADR id。 代码面只加注释:`http-dispatcher.ts` 的门序不变量此前在门旁无任何文字(现有的 ADR-0076 提及都在别处),`domains/data.ts` 补一句「域体留在这里」。逻辑零改动。 `rest-server.ts` 的 7693 行实现问题按分诊不属本单,未触碰。 Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE Co-authored-by: Claude Fable 5 --- packages/runtime/src/domains/data.ts | 7 +++++++ packages/runtime/src/http-dispatcher.ts | 10 +++++++++ scripts/adr-anchors.json | 28 +++++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/packages/runtime/src/domains/data.ts b/packages/runtime/src/domains/data.ts index e79ef3e48f..4337fe38b5 100644 --- a/packages/runtime/src/domains/data.ts +++ b/packages/runtime/src/domains/data.ts @@ -6,6 +6,13 @@ * ObjectQL fallback, ADR-0049 exposure gate inside). On a multi-tenant * host (a KernelResolver is registered) an unresolved environment answers * 428 instead of silently serving the control plane. + * + * D11 invariant: the body stays HERE. `HttpDispatcher.handleData` is a thin + * back-compat delegate, so folding this module back into it — the tempting + * "the indirection buys nothing" cleanup — re-couples the data plane to + * dispatcher state and restarts the accretion D11 decomposed. Dispatcher + * facilities are reachable only through `DomainHandlerDeps`. Anchored in + * scripts/adr-anchors.json. */ import * as actionExec from '../action-execution.js'; diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index aa5f500780..2a7d3ac04f 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1762,6 +1762,16 @@ 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 + // ── 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 + // already-scoped, already-gated request. Domains add their OWN + // authorization on top (`/ai`'s declared per-route `auth`, `/keys`' + // identity gate), but NOTHING downstream re-runs these two — so the + // 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.json. await this.resolveRequestScope(context, cleanPath); // ── ADR-0069 Authentication-policy gate ── diff --git a/scripts/adr-anchors.json b/scripts/adr-anchors.json index 352f5b5d36..ff96221b80 100644 --- a/scripts/adr-anchors.json +++ b/scripts/adr-anchors.json @@ -220,6 +220,34 @@ "ADR-0117" ], "invariant": "`OWNING_BUSINESS_UNIT_ID` is ADR-0117 D1's record-level BU ownership stamp, RESERVED-BUT-NOT-INJECTED (#4611): open-core provisions no such column yet, so the entry looks like dead weight to anyone reading the table alone — and this repo actively hunts dead surface. It is a NAME registry, not the injected set (tenant_id / user_id / deleted_at are the standing precedents), and reserving the spelling early is what stops consumers minting `business_unit_id` / `bu_id` / `dept_id` — the drift cloud#982 paid for with `tenant_id`/`org_id`/`space`. The enum value `ownership: 'business_unit'` is deliberately NOT added alongside it: `applySystemFields`' `wantOwner` is a DENY-list (`registry.ts` — only 'org'/'none' opt out), so a fourth enum member would be stamped with `owner_id`, the exact inverse of D1's table and an ADR-0049 declare-without-enforce violation. Name first, value with its injection. The public-form denylist entry is fail-closed on purpose: an ownership anchor of the owner_id/organization_id forge class must be un-suppliable on the anonymous surface BEFORE the column exists, because adding the denial after it ships is a hole with a release in it." + }, + { + "file": "packages/runtime/src/domain-handler-registry.ts", + "adrs": [ + "ADR-0076" + ], + "invariant": "ADR-0076 D11's thin dispatcher registry — the framework-agnostic port a capability's routes enter through, as a NORMALIZED handler (`DomainRequest` to `HttpDispatcherResult`), never as framework-specific routes: registering Hono `app.route` from plugin space couples every plugin to Hono and forfeits the multi-adapter property `packages/qa/http-conformance` validates on a second, zero-dependency `node:http` adapter. Deliberately WITHOUT wildcards, params or middleware — routing power belongs to the adapters below the port, and growing it here re-grows the god implementation D11 decomposed. Registration stays dispatcher-owned because most slots are MULTI-PROVIDER (`i18n`: service-i18n OR the AppPlugin in-memory fallback; `analytics`: service-analytics OR the ObjectQLPlugin fallback), so a route bridges to a SLOT and not to a package — moving registration into one provider 404s every stack served by another; a package that owns its slot exclusively self-registers through `HttpDispatcher.registerDomainHandler`. `DomainHandlerDeps` is the WHOLE dependency contract of an extracted body, and every kernel-reading facility takes the request FIRST (#5155): one dispatcher instance serves every tenant, so a cached 'kernel of the request in flight' resolved a request that resumed after an await against whichever environment was most recent — one tenant reading another tenant's data source." + }, + { + "file": "packages/runtime/src/http-dispatcher.ts", + "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." + }, + { + "file": "packages/runtime/src/domains/data.ts", + "adrs": [ + "ADR-0076" + ], + "invariant": "The `/data` body — D11 step ③'s terminal cut (PR-10) and the very handler ADR-0076 names when it describes the god implementation ('the dispatcher hardcodes a handler per domain: handleData / handleMcp / handleAnalytics / handleActions, each getService(...)'). It reaches dispatcher facilities ONLY through `DomainHandlerDeps`, and `HttpDispatcher.handleData` is a thin back-compat delegate: folding this module back into that delegate because 'the indirection buys nothing' re-couples the data plane to dispatcher state and restarts the ~3.8k-LOC accretion D11 decomposed. The multi-tenant refusal belongs to the BODY, not to an upstream gate — on a host with a registered KernelResolver, a data-plane request the resolver did not attach to an environment answers 428 and never falls through to the control-plane kernel (ADR-0006 Phase 5)." + }, + { + "file": "packages/runtime/src/domains/i18n.ts", + "adrs": [ + "ADR-0076" + ], + "invariant": "The concrete instance of D11's registration-ownership rule: the `i18n` slot is MULTI-PROVIDER — I18nServicePlugin when service-i18n is installed, else the AppPlugin in-memory fallback auto-registered for stacks that declare translation bundles — so the dispatcher registers this route, not the provider. Moving `/i18n` registration into service-i18n, the obvious-looking 'the owning package should own its route' cleanup, 404s every stack served by the other provider. The extracted body also keeps the legacy matching semantics deliberately (`match: 'prefix'`, so `/i18nxx` matches too, exactly as the old `startsWith` chain did): normalizing that edge is a behaviour change for the http-conformance suite to re-pin, not a tidy-up to slip into an unrelated diff. The 501 on an unserveable slot is D12's honest-capability rule and is narrow on purpose — both real in-memory providers self-declare `degraded` with `handlerReady: true` and keep serving, so the gate closes only on an occupant that would answer with invented strings." } ] }