DSH / Atlas
2026-08-09implementedarchitecture

An independent Events backstop closes the cordis-surface exhaustiveness gap

独立的 Events 兜底扫描补上 Cordis 表面完备性缺口

`gen-cordis-catalog` renders every service and event the Typert host-face projection discovers, and fail-closed page maps (`SERVICE_PAGE`, `EVENT_SCOPE_PAGE`) guarantee each discovered key or scope lands on exactly one `docs/subsystems/` page ([per-subsystem regions decision](../process/2026-07-28-per-subsystem-cordis-surface-regions.md) owns the page-region mechanism). Discovery itself was only backstopped for servi

English

Problem

gen-cordis-catalog renders every service and event the Typert host-face projection discovers, and fail-closed page maps (SERVICE_PAGE, EVENT_SCOPE_PAGE) guarantee each discovered key or scope lands on exactly one docs/subsystems/ page (per-subsystem regions decision owns the page-region mechanism). Discovery itself was only backstopped for services: an independent AST scan read every declare module 'cordis' Context merge and demanded each declared key be rendered or carry a named SERVICE_WALK_EXEMPTIONS reason.

Events had no such backstop. The projection walks only files reachable from host-face package exports, so an interface Events merge in client-face code — or in any file the host analyzer cannot reach — vanished with no trace: 12 declared events (slash/input-*, theme/change, locale/change, and the client runtime's */changed invalidation signals) were documented nowhere generated and nothing would ever notice a thirteenth. The services scan also globbed only packages/*/*/src/*.ts, so 13 client-face Context keys declared in nested files (src/client/**) were invisible to the very scan meant to prevent silent vanishing.

Decision

Events get the exact mirror of the services backstop, and both scans read the full package source tree.

scripts/cordis-walk.ts gains eventNameList (every member name of an interface Events merge, read from method and property members alike so a shape the projector would reject still enters the scan); the scan yields every declare module 'cordis' block in a file (the Typert analyzer reads them all, so stopping at the first would hide a second block's face), and its quote-agnostic prefilter matches the declare module heads instead of the literal text interface Context, so an Events-only or double-quoted merge file is not skipped. The scan glob in gen-cordis-catalog deepens from packages/*/*/src/*.ts to packages/*/*/src/**/*.{ts,tsx} (two patterns). A third partition direction guards the scan itself: every rendered service key and event name must also be visible to the scan, so a scan regression (glob, prefilter, block walk) is a hard error rather than a silent backstop decay.

A new curated EVENT_WALK_EXEMPTIONS map names every declared event the projection cannot see, with the reason and the package README that owns its surface. Keys are full event names, not scopes: client-face events share scopes with rendered host events (commands/changed beside the host commands/* family), so a scope-level exemption would mask a host-face regression. The partition check is fail-closed in both directions, exactly like the service maps: an unexempted invisible event, an exemption for an event that renders, and an exemption no merge declares are all hard errors.

The partition judgment moved out of computeOutputs into the pure walkPartitionProblems(input, maps) so every acceptance path is provable by unit test without running the Typert projection; computeOutputs feeds it the rendered model plus the independent scan and keeps aggregating page-splice errors as before.

The audit that motivated this found the host face already complete: 48 rendered services + 10 walk exemptions covered all 58 host-visible Context keys, all 49 host events rendered, and every type name in every rendered signature is classified by the existing fail-closed LINK_MAP/FOUNDATION_TYPE_NAMES/TYPE_LINK_EXEMPTIONS check. The 25 findings (12 events, 13 keys) were all client-face; each now carries a named exemption pointing at its owning README, consistent with the existing uiRenderer/connection precedent.

Verification

scripts/gen-cordis-catalog-partition.spec.ts proves each acceptance path: the green partition, an invisible unexempted event (named with its declaring file), a stale rendered-event exemption, a stale never-declared exemption, the service mirror of each, unmapped rendered surface in both page maps, rendered surface the scan cannot see (the third direction), and the scan reaching nested Events-only merges, every block of a multi-block file, double-quoted heads, and .tsx sources. Deleting one live exemption from the real tree makes gen-cordis-catalog fail loud with the event's name and declaring file; restoring it returns the generator to a byte-identical no-op regeneration (85 artifacts, 0 written), which also proves the new exemptions exactly cover today's surface. verify-cordis-catalog in doc-sync executes the partition on every run.

Alternatives considered

  • Render the client face instead of exempting it. Analyzing faces: ['host', 'client'] and giving client services/events generated regions is the real fix for the underlying blind spot, but it changes what the subsystems catalog IS (host-tier reference) and requires page decisions for browser-only surfaces; the existing TODO(cordis-catalog-interface-services) already tracks widening the projection. The backstop is the guarantee; rendering is an upgrade behind it.
  • Scope-level event exemptions. Smaller map, but commands/changed (client) shares the commands scope with rendered host events, so exempting a scope would swallow a future host-face event silently — the exact failure mode this note removes.
  • Deriving exhaustiveness from Typert instead of a raw AST scan. The projection and the backstop must fail independently: a Typert reachability bug is precisely what the backstop exists to catch, so the scan deliberately stays a plain ts.createSourceFile walk with no shared machinery.
  • Gating the transitive type closure of rendered signatures. Measured before deciding: every type name reachable in rendered signatures is already classified, and deeper field-of-field types are owned by the pages' hand-curated type-equiv pastes and package READMEs; a closure gate would force page homes for internals without a reader-facing need.

Consequences

A new cordis event — host or client, any file depth — must either render onto a subsystems page or name itself in EVENT_WALK_EXEMPTIONS with its documentation owner; deleting one must retire its exemption. The same now holds for Context keys declared anywhere under src/. The curated maps grew by 25 client-face entries whose reasons all point at package READMEs, keeping the subsystems catalog a host-tier reference. walkPartitionProblems is the single home of the partition judgment; future backstop dimensions (e.g. rendering the client face, schema surfaces) extend it and its spec rather than re-inlining checks into computeOutputs.

中文

问题

gen-cordis-catalog 渲染 Typert host face 投影发现的每个服务与事件,fail-closed 的页面映射(SERVICE_PAGEEVENT_SCOPE_PAGE)保证每个被发现的 key 或 scope 恰好落在一个 docs/subsystems/ 页面上(页面区块机制归按子系统区块决定所有)。但"发现"本身此前只对服务有兜底:一条独立的 AST 扫描读取每个 declare module 'cordis' Context merge,要求每个声明的 key 要么被渲染、要么在 SERVICE_WALK_EXEMPTIONS 中给出具名理由。

事件没有这样的兜底。投影只遍历从 host face 包导出可达的文件,因此 client face 代码——或 host 分析器无法触及的任何文件——里的 interface Events merge 会无声消失:12 个已声明事件(slash/input-*theme/changelocale/change,以及 client runtime 的 */changed 失效信号)不出现在任何生成文档中,而且再多一个也不会有任何机制察觉。服务扫描的 glob 也只有 packages/*/*/src/*.ts,于是声明在嵌套文件(src/client/**)中的 13 个 client face Context key 恰恰对这条为防止无声消失而存在的扫描不可见。

决策

事件获得与服务兜底完全对称的机制,且两条扫描都读取完整的包源码树。

scripts/cordis-walk.ts 新增 eventNameListinterface Events merge 的每个成员名,方法与属性成员一并读取,使投影器会拒绝的形状也进入扫描);扫描产出文件中每一个 declare module 'cordis' 块(Typert 分析器读取全部块,止步于第一个会藏起第二个块的表面),其对引号风格不敏感的预过滤匹配 declare module 头部而非字面文本 interface Context,从而不再跳过只含 Events 或使用双引号的 merge 文件。gen-cordis-catalog 的扫描 glob 从 packages/*/*/src/*.ts 加深为 packages/*/*/src/**/*.{ts,tsx}(两个 pattern)。分区新增第三个方向守住扫描自身:投影渲染的每个服务 key 与事件名也必须对扫描可见,使扫描回归(glob、预过滤、块遍历)成为硬错误而非兜底的无声退化。

新的人工维护映射 EVENT_WALK_EXEMPTIONS 为投影看不到的每个已声明事件命名,附理由与拥有其表面的包 README。键是完整事件名而非 scope:client face 事件与已渲染的 host 事件共享 scope(commands/changed 与 host 的 commands/* 家族并存),scope 级豁免会无声吞掉未来的 host face 回归。分区检查与服务映射一样双向 fail-closed:未豁免的不可见事件、已渲染事件的豁免、无任何 merge 声明的豁免,皆为硬错误。

分区判定从 computeOutputs 中提取为纯函数 walkPartitionProblems(input, maps),使每条验收路径都能以单元测试证明而无需运行 Typert 投影;computeOutputs 向它馈送渲染模型加独立扫描结果,页面拼接错误的聚合方式保持不变。

促成本决定的审计发现 host face 本已完备:48 个渲染服务 + 10 条 walk 豁免覆盖全部 58 个 host 可见 Context key,49 个 host 事件全部渲染,且每个渲染签名中的每个类型名都已被既有的 fail-closed LINK_MAP/FOUNDATION_TYPE_NAMES/TYPE_LINK_EXEMPTIONS 检查分类。25 条发现(12 事件、13 key)全部在 client face;现在每条都带指向其所属 README 的具名豁免,与既有的 uiRenderer/connection 先例一致。

验证

scripts/gen-cordis-catalog-partition.spec.ts 证明每条验收路径:绿色分区、不可见且未豁免的事件(报出声明文件)、已渲染事件的陈旧豁免、从未声明的陈旧豁免、服务侧的对称路径、两个页面映射中未映射的已渲染表面、扫描看不到的已渲染表面(第三方向),以及扫描触达嵌套的仅含 Events 的 merge、多块文件的每个块、双引号头部与 .tsx 源文件。在真实源码树上删除一条现役豁免会让 gen-cordis-catalog 以事件名与声明文件显式报错;恢复后生成器回到字节相同的 no-op 再生成(85 个产物,写入 0 个),这同时证明新豁免恰好覆盖当下表面。doc-sync 中的 verify-cordis-catalog 每次运行都会执行该分区检查。

考虑过的替代方案

  • 渲染 client face 而非豁免。faces: ['host', 'client'] 分析并给 client 服务/事件生成区块才是对盲区的根治,但它改变子系统目录的定位(host 层参考),并要求为纯浏览器表面做页面归属决策;既有的 TODO(cordis-catalog-interface-services) 已跟踪拓宽投影。兜底是保证,渲染是其上的升级。
  • scope 级事件豁免。 映射更小,但 commands/changed(client)与已渲染的 host 事件共享 commands scope,豁免整个 scope 会无声吞掉未来的 host face 事件——正是本决定要消除的失败模式。
  • 用 Typert 推导完备性而非原始 AST 扫描。 投影与兜底必须独立失败:Typert 的可达性 bug 恰是兜底要捕获的对象,因此扫描刻意保持为不共享机制的朴素 ts.createSourceFile 遍历。
  • 对渲染签名的传递类型闭包设门。 决定前先测量:渲染签名中可达的每个类型名都已分类,更深的字段套字段类型由页面手工维护的 type-equiv 粘贴与包 README 拥有;闭包门会在没有读者需求的情况下强迫内部类型认领页面。

后果

新的 cordis 事件——host 或 client、任意文件深度——必须渲染到某个子系统页面,或在 EVENT_WALK_EXEMPTIONS 中以其文档所有者具名;删除事件时必须一并退役其豁免。声明在 src/ 下任意位置的 Context key 现在同样如此。人工维护映射增加了 25 条 client face 条目,理由全部指向包 README,使子系统目录保持 host 层参考的定位。walkPartitionProblems 是分区判定的唯一居所;未来的兜底维度(如渲染 client face、schema 表面)应扩展它及其 spec,而非把检查重新内联进 computeOutputs