# ETAF 架构 本文是 ETAF 的规范性架构契约,定义概念、职责、公共语法和 lowering 路径。实现状态与后续工作写在 [`implementation-plan.zh.md`](implementation-plan.zh.md),面向用户的使用方式写在 [`user-guide.zh.md`](user-guide.zh.md)。 ## 1. 设计结果 ETAF 是面向文本应用的统一 View 与 Component 层。Elisp 仍然是完整的计算语言,Ebox 仍然是负责可测量布局和文本渲染的底层引擎。 ```text 应用 → Runtime / 响应式状态 / Action / Data → Component(props, 局部 Scope) → View → Renderer → Ebox Node → measure → layout → paint → commit → Emacs buffer ``` 设计有五条不变量: - 所有可见结构都使用同一种 `NAME + 属性 + 子节点` 形状。 - 每项能力只有一个 owner:View 负责结构,Component 负责复用,Runtime 负责生命周期,响应式状态负责失效,Ebox 负责几何和发布。 - 上层复用下层契约,不复制下层逻辑,也不引入平行概念。 - 计算在 Elisp 表达式位置中完成,不伪装成另一类视觉节点。 - 渲染候选失败时,绝不会替换上一次已提交的 buffer。 ## 2. 用户需要学习的模型 | 概念 | 它是什么 | 它负责什么 | | --- | --- | --- | | View | 规范化后的界面描述 | Host、Component 调用、文本和子结构 | | Host | 固定的结构性 View 名称 | 核心文本或布局语义 | | Component | 可复用的 View 生产者 | Props、可选局部 Scope、slot 和生命周期 | | Runtime | 一次挂载的应用运行 | 渲染、事件、调度、提交、回滚和释放 | | Ebox Node | 更底层的可渲染对象 | 几何、布局、surface、滚动和 buffer 发布 | 以下是机制,不是额外的视觉节点类型: - `expr` 计算一个子节点位置的表达式。 - `slot` 读写同一个 Component slot 集合。 - `Behavior` 安装可复用的非视觉交互能力。 - `Action` 命名业务状态变更入口。 - `Effect` 管理订阅和外部同步。 - `watch` 观察响应式状态。 - `Context` 提供继承的依赖。 - `Data` 管理应用数据状态和数据源请求。 ## 3. 统一 View 语法 每个 Host 和 Component 调用都使用一种形状: ```text (NAME ATTRIBUTE* CHILD*) ATTRIBUTE = :KEY VALUE ``` 属性必须在第一个子节点之前全部结束。子节点可以是字符串、规范化 View、`nil`,或者由 `expr` 返回的序列。 ```elisp (etaf-view (column :class "welcome" (text :font-weight 'bold "Hello") (text :color "#687386" (expr (if ready "Ready" "Waiting"))))) ``` 属性出现在子节点之后时,属性区和子节点区被交错,属于非法结构。 `etaf-view` 是唯一的公共结构构造入口。宏在结构位置读取 View form 并生成规范化 View 值;`etaf-render` 负责纯 View 的 lowering,`etaf-mount` 为 View 建立有状态 Runtime 并发布到 buffer。 ### 3.1 quote 与求值 规则只有两条: 1. 结构性 View 位置不需要 quote,包括 `etaf-view`、Host、Component 调用、子节点、slot 和静态 Component styles。 2. Elisp 表达式位置遵循普通 Elisp 求值,包括属性值、`:key`、`:on-*`、`:use`、`expr`、`:setup`、Context 值、Behavior 构造器和 Action。 ```elisp (etaf-view (text :color (if dark "#F4F6FB" "#1F2328") (expr label))) (etaf-view (column (expr (when open (etaf-view (text :font-weight 'bold "Details")))))) ``` `'bold` 是普通 Elisp 字面量 symbol。`'(text "Details")` 只是普通数据,不是 View;当 Elisp 表达式需要构造 View 时,使用 `(etaf-view (text "Details"))`。ETAF 不会对被 quote 的 View 数据再次 `eval`,也不增加单独的 literal/eval 节点。 属性值不需要 `expr` 包装。`expr` 只因为子节点区是结构语法,需要一个明确的桥接点来执行任意 Elisp。 ### 3.2 expr 语义 `expr` 只接受一个属性且不能有子节点: ```elisp (expr ELISP-EXPRESSION) ``` 它执行表达式,然后接受字符串、typed View、typed View proper sequence 或 `nil`。 它不创建 Ebox wrapper、identity、生命周期、watch 或 effect。`if`、`when`、`cond`、 `let`、`mapcar`、`cl-loop` 等仍是 form 中的普通 Elisp。 当前 core 没有面向文件的 `.etaf` pair loader。`etaf-define-component` 才是结构/样式/行为单元:View 定义结构,`:styles` 负责 presentation,`:setup` 负责 retained state、事件和生命周期行为。未来的 `.etaf` SFC 属于把结果 lowering 到同一套公共 View/Component 契约的 compiler layer,而不是第二套 Runtime 语法。 ## 4. Component 公共定义宏有四个关键字。两个 frontend 必须二选一,另外两个 clause 可选: ```text (etaf-define-component NAME (&key PROPS) DOCSTRING? :setup OPAQUE-STATE-FORM :view VIEW :styles (styles RULE...)) (etaf-define-component NAME (&key PROPS) DOCSTRING? :setup OPAQUE-STATE-FORM :render ORDINARY-ELISP :styles (styles RULE...)) ``` `:view` 和 `:render` 互斥且必须恰好出现一个。`:render` 是普通 Elisp,可以返回 `etaf-view` 或程序化构造的 `etaf-node`;两者共用 View 编译、prop 校验与 slot 投影,不增加另一套表示或状态模型。`:setup` 与 `:styles` 可选且各最多出现一次。Props 是唯一需要声明的业务输入;普通尾部子节点和命名 slot 会被规范化为 Component 的 slot 集合。 Component definition 是当前结构/样式/行为边界:动态状态、Action callback 和生命周期工作放进 `:setup`,静态 presentation 放进 `:styles`。未来 `.etaf` SFC compiler 可以生成这些 definition,但 Runtime 不会直接加载 `.etaf` 文件。 ### 4.1 无状态与状态型形式 ```elisp (etaf-define-component status-label (&key label) "Render a status label." :view (text :font-weight 'bold (expr label))) ``` ```elisp (etaf-define-component disclosure (&key title) "Render a retained disclosure." :setup (etaf-ref nil) :view (column (text :role 'button :on-press (let ((open (etaf-state))) (lambda () (setf (etaf-value open) (not (etaf-value open))))) (expr (if (etaf-value (etaf-state)) "Hide" "Show"))) (expr (when (etaf-value (etaf-state)) (etaf-view (text (expr title))))))) ``` `:setup` 对一个 retained Component instance 只运行一次,返回一个 opaque 状态值。 `:view` 或 `:render` 中用 `etaf-state` 取得这个准确值。重新渲染读取当前 props 和 state,不重新运行 setup。setup 负责局部 ref、computed、watch、Effect 和 cleanup, 不返回 render 函数。 `:key` 是稳定的 identity metadata,不是业务 prop。放在 Component 调用上时,它选择同级作用域内要保留的 Component instance;放在 Host 上时,它会作为 Ebox node key 向下传递。候选渲染失败时,Runtime 恢复旧 instance、handlers、Behaviors 和 buffer。 Component 调用使用定义时准确注册的名称,注册表不会自动生成省略前缀或 `-view` alias。官方目录组件使用 `etaf-button` 等名称;应用可以显式使用任何合法且尚未占用的名称定义自己的组件。 ## 5. children 与 slot 所有 Component 内容都是同一个 slot 集合: ```text slots.default = 普通尾部子节点 slots.NAME = 命名 slot 内容 ``` children 只是匿名/默认 slot 的便捷写法,不是第二套内容模型,也不需要出现在业务 `&key` 声明中。 尾部子节点自动填充默认 slot: ```elisp (card :title "Account" (text "Card body")) ``` 命名内容使用同样的结构形状: ```elisp (card :title "Account" (slot :name 'header (text :font-weight 'bold "Account settings")) (text "Card body")) ``` 在 Component View 内,默认 outlet 以及 fallback 写法是: ```elisp (slot) (slot (text :color "#687386" "No content")) ``` 完整的内部规范写法是: ```elisp (slot :name 'default (text :color "#687386" "No content")) ``` 对用户来说,优先使用前两个简写;只有需要明确名字时才写 `:name`。Slot 名称必须是稳定的、非 keyword 的 symbol。字符串、数字、变量和运行时表达式都会被拒绝,因为 slot 名称属于 retained 结构。同名输入只能出现一次。显式空输入 `(slot :name 'header)` 会抑制 outlet fallback。Slot form 不创建 Ebox wrapper。 在 Component 内,`slot` 表示投影;在 Component 调用的子节点区,带 `:name` 的 `slot` 表示贡献内容。编译器对两种位置使用同一个规范化 slot 表示。 Slot 中作者写下的表达式在整个投影子树中保留作者的 props、state 和 Context。 其中创建的 Component 拥有自己的 props、state、styles 和 Scope,但其 Context 从 slot 作者环境继承。接收方自己定义的 fallback、普通子节点和产生 View 的 callback 使用接收方环境。Table/Grid 的 cell callback 遵循相同的普通规则: Context 来自消费它的 Table/Grid 所在位置。 ## 6. Core Host 与 Ebox ETAF core 只提供最小且无样式的 Host: ```text text · fragment · container · row · column · stack · flex · grid · spacer ``` | Host | 作者看到的含义 | lowering 方向 | | --- | --- | --- | | `text` | 带可选 inline runs 的文本 surface | Ebox box/content | | `fragment` | 不增加视觉 wrapper 的子节点集合 | 展平的子节点序列 | | `container` | 中性的子节点容器 | Ebox container/column 路径 | | `row` | 水平排列子节点 | Ebox row layout | | `column` | 垂直排列子节点 | Ebox column layout | | `stack` | 结构性的组合容器 | Ebox container 路径 | | `flex` | 通过 flex 分配空间 | Ebox flex layout | | `grid` | 二维轨道、放置和跨度 | Ebox Grid formatting context | | `spacer` | 有意表达的空几何 | Ebox spacer | 字符串是最小的文本 View,会降低为 Ebox content。text 中兼容的嵌套 text 会成为带 text properties 的 inline run;非文本子节点则回到普通布局 lowering。因此 Text、View Host、Component 和 Ebox Node 是连续的表示层,而不是三棵相互竞争的树。 `grid` 复用 Ebox 的公共二维布局契约,支持轨道模板、`auto`/分数/`minmax` 轨道、间距、行列放置、跨度、自动流向和 item 对齐。轨道测量和放置由 Ebox 负责;ETAF 只把 `grid` Host 映射到这个节点。Ebox 的可选 native reflow 后端不参与正确性保证;如果该后端不支持 Grid,Grid 树会使用普通 Ebox 渲染器。 ETAF 的 Renderer 是唯一把 View 语义 lower 为 Ebox 节点、属性与 Host 查询的框架模块;`etaf-render-port.el` 是唯一探测 versioned Ebox framework SPI 并选择发布路径的模块。两者都只使用 Ebox 公共 API,Runtime 只读取已经选定的 immutable port,不再自行猜测 Ebox 版本。Ebox 不理解 Component、slot、Action、Context、Behavior 或 Data。 公共 View 语法不接受裸 Ebox Node。框架集成层通过 Ebox 的 typed integration port 构造规范的 TextNode 和 BoxNode;普通 ETAF 应用只使用 Host 与 Component。这样 measurement、identity 和 rollback 始终由同一条 lowering 路径负责。 ## 7. Runtime 与响应式状态 Runtime 的挂载是事务性的: ```text 创建 Runtime → 建立 retained Component scope → 渲染 View 候选 → lowering 并发布 Ebox 候选 → 提升 handlers、instances 和 Behaviors → 运行 mounted/updated 生命周期 ``` 更新使用同一条路径。响应式 ref 和 computed 让 render effect 失效;Runtime scheduler 在当前边界同步 flush。渲染阶段写入状态会触发 `etaf-render-write-error`;状态应在事件、Action、Effect 或 watch callback 中改变。 渲染、lowering 和 Ebox 发布构成回滚边界。如果候选步骤之一失败,上一棵已提交的树仍保持 active。生命周期和 cleanup callback 在 retained state 提升且发布完成后运行;它们的错误会保持可见,但不会假装回滚已经发布的 Ebox tree。 响应式 API 只有一套模型: ```elisp (let* ((count (etaf-ref 0)) (double (etaf-computed (lambda () (* 2 (etaf-value count)))))) (etaf-watch count (lambda (new old) (message "%s → %s" old new))) (etaf-watch-effect (lambda () (message "double=%s" (etaf-value double))))) ``` `etaf-effect-scope` 负责 effects 和 cleanup。Component setup 自动运行在 Component Scope 内;Component 释放时,会停止子 scope、watcher 和 resource cleanup。 ## 8. Behavior、事件、Action 与 Effect ```text on-xx = 一个局部事件属性 Action = 一个命名的业务变更入口 Effect = 一个订阅/外部同步 owner Behavior = 安装多个非视觉能力的可复用 bundle ``` 一次性的交互直接使用 callback: ```elisp (text :role 'button :on-press (lambda () (message "Opened")) "Open") ``` 当变更需要命名并被多个入口复用时,使用 `etaf-action-define` 和 `etaf-dispatch`。当多个 Host 需要同一套非视觉能力时,使用 `etaf-define-behavior` 或 `etaf-behavior-create`,并通过 `:use` 安装。Behavior 不是 View 节点,也不直接修改 buffer。 `etaf-behavior-create` 接受保留的 `:install` 属性,用于可选的零参数 installer。Installer 可以返回 cleanup;运行期间可通过 `etaf-current-behavior-context` 读取当前 Runtime、结构路径和 Host props。Behavior 被替换或 owner 卸载时,installer 状态会被释放。 交互组合是确定的:显式 Host `:on-*` callback 先运行,然后按声明顺序运行 Behavior callback;任一错误会 short-circuit 后续 callback。非事件属性由显式 Host 值优先, 否则第一个声明该属性的 Behavior 获胜(first-wins)。同一 Host 的 Behavior name 必须在任何 installer 运行前保持唯一。稳定 installer identity 会复用,每个已安装 cleanup exactly-once。事件只 dispatch 给准确 Host ref;ETAF 没有 capture 或 bubble 阶段。 Action name 应使用 application/feature-prefixed symbol。重复注册默认报错。 `etaf-action-redefine-run` 是显式 authoring/reload 边界;替换只影响未来按 name 的 dispatch,不会 flush 已挂载 Runtime。 Runtime 事件通过 `etaf-dispatch-event` 进入;命中测试和 focus 通过 Ebox Host 引用查询,并由 `etaf-activate`、`etaf-focus`、`etaf-focus-next`、`etaf-host-ref-bounds` 和 `etaf-host-ref-position` 提供公共入口。 ## 9. Context、Theme、Data 与 Resource ### 9.1 Context 与 Theme Context 是继承的 Component Scope 环境: ```elisp (require 'etaf) (etaf-define-component service-consumer () "Read the inherited service." :setup (etaf-inject 'service nil t) :view (text (expr (format "Service: %s" (etaf-value (etaf-state)))))) (etaf-define-component service-provider () "Provide a reactive service to its own child Component." :setup (let ((service (etaf-ref "demo-service"))) (etaf-provide 'service service) service) :view (service-consumer)) (etaf-mount "*etaf-context*" (etaf-view (service-provider))) ``` key 使用稳定的普通 symbol。最近的祖先优先,缺失的 required key 触发 `etaf-context-error`。在根位置编写的 slot 内容及其子树保留根的空 Context。 迁移旧版本中意外继承接收方 Context 的用法时,应把消费者写进 provider 自己的 View,或在那里调用普通的 View-producing callback。投影的 slot 继续使用作者 环境。Theme 遵循同一套 Context 继承关系,内容是属性 plist: ```elisp (require 'etaf) (etaf-define-component themed-shell () "Provide semantic colors to its own View." :setup (etaf-theme-provide '(:text-color "#F4F6FB" :surface-color "#202634")) :view (text :ref 'themed-content :color (etaf-theme-token :text-color) :background-color (etaf-theme-token :surface-color) "Themed content")) (etaf-mount "*etaf-theme*" (etaf-view (themed-shell))) ``` Palette 解析属于 Theme,而不是 UI 目录。core 提供 `etaf-theme-resolve-palette` 来解析显式的亮/暗语义 pair;可选的 `etaf-theme-tp` 文件是唯一可以读取 TP renderer-level palette registry 的桥接层。 `etaf-ui` 只消费 `:ui-*` 语义 token,不依赖 TP 名称,也不访问 Ebox/TP 私有状态。 静态 Component 规则如果需要读取一个 Theme 值,应使用 `etaf-theme-token`; ETAF 会在样式边界解析这个延迟 token,使 retained Host 仍然走静态样式路径。 显式 Host props 覆盖 Component styles,Component styles 覆盖 Theme defaults。 样式所有权跟随创建每个 View node 的 Component。嵌套 Component 是样式边界:父规则不会穿透其内部。调用者提供的 slot 内容使用 caller scope,子组件自有的 fallback 内容保留在 child scope。 ### 9.2 Data Data 是 ETAF core 能力,不是需要用户额外学习的第二套框架。Data Source 是一个小的 capability plist: ```elisp (etaf-data-source :load (lambda (query page page-size) (ignore query page page-size) (let ((rows '((:id 1 :name "Ada")))) (list :items rows :total (length rows)))) :mutate (lambda (operation payload) (ignore operation payload) t) :dispose (lambda () t)) ``` `:load` 必选,接收 `QUERY`、`PAGE` 和 `PAGE-SIZE`,返回带 `:items` 的 plist,可选 `:total`、`:page` 和 `:page-size`。`:mutate` 和 `:dispose` 可选。核心边界刻意是同步的,因此不需要 Promise、Task 或 Executor 概念;外部 callback 型集成可以通过同一套响应式 ref 或 Resource 边界发布结果。 `etaf-data-controller` 负责 query、分页、items、total、status、error、selection、request generation 和释放。`etaf-data-memory-source` 是示例和测试使用的内存 source。存储包只是具体 source;SQLite 不是 core 前提,ORM 也保持在 ETAF 数据模型之外。 ### 9.3 Resource 与 Error Boundary `etaf-resource` 是 Scope 所有的同步 loader,拥有响应式的 `loading`、`success` 和 `error` 状态: ```elisp (let* ((filename "README.md") (resource (etaf-resource (lambda () (with-temp-buffer (insert-file-contents filename) (buffer-string))))) (stop (etaf-watch-effect (lambda () (message "resource=%s" (etaf-resource-status resource)))))) (unwind-protect (etaf-resource-value resource) (funcall stop) (etaf-resource-dispose resource))) ``` `etaf-resource-result` 为替换和释放提供 cleanup。`etaf-error-boundary-run` 是明确的函数边界:它处理 body 抛出的错误,让边界外的错误保持可见。Resource 和 Data 状态通过普通 `expr` 分支投影,不增加专门的 Error 或 Loading 节点。 ## 10. 官方 UI 与包边界 正式的可复用界面概念只有 Component: ```text ebox └── 可选的 ebox-playground etaf → ebox ├── View / Component / Runtime ├── reactive / Action / Effect / Data └── Renderer etaf-ui → etaf └── 官方 Button、Checkbox、Label、Panel、DataGrid 等 Component etaf-sqlite → etaf └── 类型化 SQLite Data Source etaf-playground → etaf └── 展示官方 Component 时可选依赖 etaf-ui ``` `etaf-ui` 是官方现成 Component 目录。用户只需要理解 Component;文件只是维护者边界。Controls、Widgets 和 DataGrid 不是平行的 Runtime 类型,DataGrid 只是由同一套 View、props、slot、事件和 Data 契约构成的复合 Component。 `etaf-sqlite` 是具体的数据源包,负责 schema 声明、标识符校验、连接、分页和 mutation;`etaf-data` 仍然属于 ETAF core。PostgreSQL、REST、文件和 ORM 集成可以在独立包中实现同一个 source capability,不增加 `etaf-adapters` 概念。 `ebox-playground`、`etaf-playground`、`etaf-ui` 和 `etaf-sqlite` 都是可以独立加载的同级包。`ebox-playground` 只使用 Ebox 公共 API,不加载 ETAF;`etaf-playground` 使用 ETAF 公共 API,并且只在展示官方组件时可选加载 `etaf-ui`,不依赖 `ebox-playground`。核心包不会自动加载任何可选包。 可选存储集成应使用具体名称,例如 SQLite 或 PostgreSQL source。通用 adapter 包无法拥有稳定的行为,只会增加用户需要记忆的名称,因此不属于公共模型。 ## 11. 保留式发布与 fixed-point 安全 mounted Component、expr、slot、fragment、raw、inline 和 Root owner 都经过同一 个外层 reactive dispatch。局部 owner 先生成 candidate generation 和 Ebox logical replacement;不相交 owner 会合并为一次 TP/Ebox publication。只有 Root owner 可以进入 complete-root adapter。 已提交 semantic generation 的唯一 mutable pointer 由 `etaf-generation-authority` 持有。handler 与 Host-prop 等公共查询直接读取该 generation;Runtime 中同名 hash table 只是由 generation 单向重建的兼容 mirror, 既不能独立授权查询,也不能反向改写 generation。迁移期的 `legacy`/`project`/`shadow` 路由只用于证明投影等价与安全回退,不增加第二份 committed truth。 进程 bootstrap 时,`etaf-render-port` 要求 Ebox framework SPI v2,并 snapshot 唯一的不可变 port。provider 缺失、格式错误或不兼容都会在 Runtime mount 前 fail closed。按依赖顺序升级时,Ebox 可暂时报告 TP v1+v2 双能力 manifest,也可报告最终 的 v2-only manifest;两者都证明所需的 v2 capability,ETAF 不会调用 v1 operation。 mounted 或 in-flight Runtime 绝不会切换 port。 每个 semantic candidate 同时捕获 expected generation、semantic token,以及 instance/resource/artifact/route store versions。render 路径在 Ebox framework stage 中暂存一次 CAS,失败时由同一个 inverse journal 恢复;没有可见 Ebox 变化的 semantic-only 路径由 ETAF 自己执行同一 CAS,不创建 Ebox commit 或 TP revision。CAS 后的 mirror 与旧 route 清理属于 postcommit,失败不能反向恢复已提交 token;这类失败会写入 retirement diagnostic journal,并用与 lifecycle failure 相同的 committed trailer 重新 signal,绝不会重新进入 semantic rollback。 每个 mounted Runtime 还拥有独立的 Host authority:`state / opaque token / version`。initial v2 framework stage 只进入 provisional state,并向 TP 注册固定 slot final marker;只有 buffer final accept 成功时 Host 才成为 attached。公开 lookup、event 和 source route 同时校验 attached state 与 token。detach 先在 O(1) 边界让 token 失效,再清理 registry、routes、Component scopes 和 Behaviors;因此 cleanup 数量或错误不能让旧 Host 重新获得 authority。 lifecycle callback 与结构 cleanup 在该 commit boundary 之后进入 `etaf-retirement-journal`。每个 entry 都有稳定 identity、ordering key、attempt count、policy 与 terminal state。mounted、updated、unmounted 等公开 callback 只运行一次;第一个公开 callback 失败后,后续公开 callback 标记为 abandoned, 但结构 cleanup 继续。可幂等的框架 cleanup 只做有上限的重试;contained cleanup 只写 diagnostics,不改变已提交 generation、Ebox revision 或 Host authority。 Runtime 以有界历史保存已完成 journal,且它们与 committed outcome 分离。 当显式操作需要向调用者暴露 postcommit callback 错误时,ETAF 会用原 condition symbol 重新 signal,并逐项保留原 condition data 前缀;末尾只追加一个固定 `:etaf-condition-trailer/v1` datum,其中包含 operation、outcome、generation、 Ebox revision 与 diagnostic-journal ID。`etaf-condition-postcommit-info` 会校验并 读取该 trailer,让调用者机械区分“已经提交、随后 callback 失败”和 rollback failure。buffer-kill 路径会 drain 或 contain retirement 工作,但绝不会从 kill hook 抛 retirement condition。 Reactive publication 由显式 `etaf-scheduler-context` 协调。context 拥有 source 与 Runtime FIFO、对应 dedupe set、effect claim、turn/projection epoch、 nesting/busy 状态、fault diagnostics 与成本计数器;它不拥有 Component、 resource 或 generation state。既有调用者继续使用 `etaf-scheduler-default-context`,mount 也可以传入 `:scheduler-context` 隔离 dispatch authority。Scope、Effect 与 opaque Runtime route 会继承并保留该 context。 一次 logical projection 会先按 live subscriber context 对所有 changed source 在一次 subscriber-table scan 中分组;所有已触及 context 的 source propagation 都稳定后,Runtime callback 才能 publish。在同一 context 中,每个 source 与 Effect 每个 scheduler turn 只 delivery 一次,多个 changed source 也只 enqueue 同一 Runtime 一次。已经 delivery 的 source 若重入写入,会延后到下一个 turn; per-context turn budget 会包含跨 context cycle,并留下可复用的 fault diagnostics。 每个 source wave 都会 snapshot 当前 FIFO,因此 wave 中发现的不同 source 必须在 下一个受预算约束的 turn 执行,不能垄断一次 drain。context A 的 dedupe 不会压制 context B;registry/token/Host 校验会在 fan-out 前过滤 stale Runtime route。 Runtime callback 先作为一个完整 turn detach,因此 lifecycle write 会进入下一个 turn;某个 callback 失败时,scheduler 会先执行该 detached turn 中剩余 callback, 再把首个 condition 记录为 context fault。Data success/error 的 multi-ref publication 与 event/action callback 都复用同一个 projection boundary,legacy façade 则继续走 default context。Data source failure 会更新 Controller error state; 之后发生的 projection/render failure 保持原 condition,不能被重新归类为 source failure。Runtime operation report 同时携带本 context delta 与完整 cross-context projection summary,覆盖 source delivery、 subscriber visit、effect work、Runtime work、stale drop、turn 与 fault。 每次 Runtime flush 都记录 candidate-aware effect tuple:其中包含 generation id、 effect→source 边和 source version,以及 candidate input/context/output facts 的 immutable semantic-node stamp。重复 tuple 会报告有序的 effect/edge path;step bound 从 candidate nodes、dependency edges 和 source entries 推导,不再是任意 固定倍率阈值。rollback 会丢弃本次 flush 的 stamp,因此同一个失败状态可以作为 新的 transaction 重试。 Behavior installer 使用 target-specific identity:reactive value 和 function 用 `eq`,普通 scalar attribute 才使用 value equality。每个已安装 Behavior 都获得 稳定的 `(mount-epoch resource-id)` 地址;generation membership 与 Runtime resource registry 决定当前 authority。失败 candidate 只移除 staged Behavior resource,并 执行受 containment 保护的 cleanup。 ## 12. 拓展规则 增加新能力前,优先选择最小的既有 owner: | 需求 | Owner | | --- | --- | | 可复用的视觉组合 | Component 或普通 View helper | | 一个子节点计算 | `expr` | | 局部派生值 | `etaf-computed` | | 可复用交互 | Behavior | | 命名变更 | Action | | 跨层级依赖 | Context | | 请求或变更状态 | Data / Resource | | 几何或布局算法 | Ebox | 只有在现有 owner 无法表达、能够明确 identity/lifecycle/error/rollback 规则,并且可以用公共路径测试证明时,才增加新的公共概念。这样既保留完整的 Elisp 表达能力,又让用户模型保持干净。