diff --git a/apps/docs/src/styles/globals.css b/apps/docs/src/styles/globals.css
index fa77bf6..ded8101 100644
--- a/apps/docs/src/styles/globals.css
+++ b/apps/docs/src/styles/globals.css
@@ -455,7 +455,7 @@ button {
}
.doc-cards {
display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px;
}
.doc-card {
@@ -1340,3 +1340,167 @@ button {
border: 0;
border-radius: 0;
}
+
+/* The topology stays a reproducible SVG; React only supplies the viewer. */
+.architecture-figure {
+ display: block;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ overflow: hidden;
+ margin: 28px 0;
+}
+.figure-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 16px;
+ background: #f5f8f3;
+ font:
+ 11px ui-monospace,
+ monospace;
+ color: #6c806f;
+}
+.figure-toolbar button {
+ color: var(--green);
+ border: 1px solid #d7e3d4;
+ padding: 5px 10px;
+ border-radius: 5px;
+ background: white;
+}
+.figure-preview {
+ display: block;
+ width: 100%;
+ background: #fcfdfb;
+ cursor: zoom-in;
+}
+.prose .figure-preview img {
+ display: block;
+ width: 100%;
+ padding: 0;
+ margin: 0;
+ border: 0;
+ border-radius: 0;
+}
+.figure-caption {
+ display: block;
+ padding: 10px 16px;
+ font-size: 11px;
+ color: #758576;
+ border-top: 1px solid var(--line);
+}
+.figure-dialog {
+ padding: 0;
+ width: 96vw;
+ max-width: 1900px;
+ height: 94dvh;
+ max-height: 94dvh;
+ border: 1px solid #dce5d9;
+ border-radius: 12px;
+ margin: auto;
+ overflow: hidden;
+ background: #fcfdfb;
+ box-shadow: 0 20px 100px #18312530;
+}
+.figure-dialog::backdrop {
+ background: #1e322e66;
+ backdrop-filter: blur(4px);
+}
+.figure-dialog[open] {
+ display: flex;
+ flex-direction: column;
+}
+.figure-viewer-toolbar {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+ padding: 12px 18px;
+ background: #fff;
+ border-bottom: 1px solid var(--line);
+ align-items: center;
+}
+.figure-viewer-toolbar strong {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--ink);
+}
+.figure-viewer-toolbar small {
+ color: #a68b52;
+ font-size: 11px;
+ margin-left: 5px;
+}
+.figure-controls {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ font-size: 11px;
+ color: #647c67;
+}
+.figure-controls button,
+.figure-controls a {
+ min-height: 32px;
+ min-width: 32px;
+ padding: 4px 9px;
+ border: 1px solid #dae4d6;
+ border-radius: 5px;
+ background: #fff;
+ font-weight: 400;
+ text-decoration: none;
+}
+.figure-controls button:disabled {
+ opacity: 0.35;
+ cursor: default;
+}
+.figure-controls > span {
+ min-width: 62px;
+ text-align: center;
+}
+.figure-pan {
+ overflow: auto;
+ flex: 1;
+ min-height: 0;
+ overscroll-behavior: contain;
+ padding: 8px;
+}
+.prose .figure-pan img {
+ display: block;
+ max-width: none;
+ height: auto;
+ margin: 0 auto;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: #fcfdfb;
+}
+.figure-viewer-hint {
+ margin: 0 !important;
+ padding: 10px 18px;
+ border-top: 1px solid var(--line);
+ background: #fff;
+ color: #798d79;
+ font-size: 10px !important;
+ line-height: 1.7 !important;
+}
+@media (max-width: 560px) {
+ .figure-viewer-toolbar {
+ padding: 10px;
+ gap: 8px;
+ }
+ .figure-controls {
+ gap: 5px;
+ }
+ .figure-controls button,
+ .figure-controls a {
+ min-height: 36px;
+ }
+ .figure-toolbar {
+ padding: 10px;
+ font-size: 10px;
+ }
+ .figure-caption {
+ font-size: 10px;
+ padding: 10px;
+ }
+}
diff --git a/content/docs/00-endor-concepts-draft.md b/content/docs/00-endor-concepts-draft.md
new file mode 100644
index 0000000..234ddfa
--- /dev/null
+++ b/content/docs/00-endor-concepts-draft.md
@@ -0,0 +1,110 @@
+# 理解 Endor:从全球入口到对象数据
+
+Endor 的概念模型把全球访问、区域运行时和对象数据连接起来:请求从统一入口进入,在合适的区域找到 Nexus Runtime,再由它承载的 Aster VM 执行对象操作。数据以分片组织,在区域内保持高可用,并为每个分片安排跨区域恢复目标。
+
+> **概念设计草案**:本文按当前讨论绘制目标架构,不表示这些组件和协议已经全部实现。图中的区域、节点、分片数量仅为示例;CDN 和身份服务的接入方式是建议方案。
+
+## 架构总览
+
+
+
+图中绿色/蓝色实线表示请求路径或区域内数据依赖,紫色虚线表示身份控制与公钥分发,蓝色点线表示 CDN 回源,金色虚线表示跨区复制和备份。连线表示可达或依赖关系,**不表示请求向所有节点广播**。
+
+图的编排由 Python 生成 SVG:节点图标、二分全连接和复制线路都能复现和编辑。Python 更适合这张静态架构图;页面查看、缩放与下载由 TypeScript/React 负责。
+
+## 全球入口与区域入口
+
+客户端包括浏览器、Tessera、SDK 和 CLI。`endorphin API` 是逻辑上的全球流量入口,尚未在本文规定一个真实的域名。全球调度根据健康、延迟、项目归属和数据放置策略选择区域;不能只选择网络最近的区域而忽略对象在哪个分片。
+
+示例使用北美和亚洲两个区域。每个区域有自己的负载均衡入口,下面是 Traefik 网关,负责按配置进行 TLS、主机名或路径路由。图中的第二个 Traefik 标记为 **HA 副本**:多副本是生产冗余选项,单实例概念部署不要求两个网关。
+
+### 为什么画成二分全连接
+
+每个区域示例中有两个 Traefik 和三个 Nexus Runtime,画出全部六条边,即二分图 **K₂,₃**。每个网关都能把请求路由到任意一个符合条件的健康 Runtime,避免把一个网关固定绑定到一个运行时。
+
+这是逻辑可达关系。实际转发仍需考虑服务发现、健康检查、项目和对象归属,以及有状态连接的路由策略;Runtime 之间并没有因此形成全连接网状网络。
+
+## Nexus Runtime 与 Aster VM
+
+此处采用 **一个 Nexus Runtime 节点配一个 Aster VM** 的概念约定。Nexus 表示对外接入与运行时承载边界,Aster VM 执行对象相关逻辑。图中每台服务器下面都单独画出一个 VM 芯片,避免把多个 VM 误读成另一个共享池。
+
+请求经过区域入口和 Traefik 到达 Nexus,再定位目标分片。图中运行时下方的连接汇入分片路由层,表示节点可以按归属访问区域分片,**不是**「Runtime 1 只能访问分片 1」。对象身份、远程引用、生命周期与调用语义仍需结合[节点与对象挂载草案](/docs/02-mounting-draft)继续定义。
+
+## 三类存储如何组成一个分片
+
+示例中每个区域都有两个逻辑数据分片。每个分片展示三个协作部分,它们是逻辑数据边界,不要求一分片对应三台独占物理机器。
+
+| 部分 | 概念职责 | 依赖关系 |
+| ------------------------- | ------------------------------------------------------------ | ------------------------- |
+| PostgreSQL | 结构化元数据、对象索引及需要事务约束的状态;具体数据模型待定 | 提供关系数据与事务能力 |
+| Aerarium Object data pool | 对象数据的逻辑组织与访问层;不是把 S3 换一个名字 | 同时依赖 PostgreSQL 与 S3 |
+| S3 兼容存储 | Blob、文件、快照和适合对象存储的数据;具体格式待定 | 提供对象持久化能力 |
+
+图中 Aerarium 向左右各画一条依赖箭头,分别连接 PostgreSQL 和 S3。它们**不是**三个互不相关的平行数据池。
+
+### 区域内高可用
+
+每个存储图标下面有一个小型副本图标,用点线连接,表示本区域的高可用副本。生产部署应跨独立故障域放置,具体可以是不同可用区或独立节点。
+
+这是逻辑 HA 要求,各层的实现不同:PostgreSQL 可以使用主备复制,Aerarium 需要自己的状态协调和故障恢复设计,S3 的耐久性与副本由选定的存储系统提供。图不强制三者采用同一种同步复制机制,也不把单纯增加副本等同于已经实现故障自动切换。
+
+## 每个分片都要有跨区恢复目标
+
+区域内副本解决本地节点或故障域失效;跨区备份解决整个区域不可用。四个源分片均有独立的异地目标:
+
+| 源分片 | 异地备份位置 | 覆盖内容 |
+| ------------ | ------------ | -------------------------------------------- |
+| NA-01、NA-02 | 亚洲区域 | 各分片的 PostgreSQL、Aerarium 状态和 S3 数据 |
+| AS-01、AS-02 | 北美区域 | 各分片的 PostgreSQL、Aerarium 状态和 S3 数据 |
+
+每个目标应保留可校验的恢复清单,将关系数据恢复点、对象池元数据版本与 S3 对象版本关联起来;不能只复制其中一种存储就宣称整个分片可恢复。图中的金色线路明确跨越区域,备份集按源分片编号标识。
+
+### “最终一定拿到数据”需要怎样的保证
+
+设计目标是:正常访问优先本区域;本地不可用时,仍能找到异地保存的数据。需要区分两类能力:
+
+- **异地在线副本**:已有服务能读取它;追平状态、授权和路由满足要求后,才可承担故障读取。
+- **异地备份**:保留了恢复材料,需要恢复 PostgreSQL、重建 Aerarium 并校验对象版本后才能提供服务,通常更慢。
+
+> **保证边界**:异步复制与定期备份不能无条件保证最后一笔已确认写入不会丢失。若源区在复制完成前永久丢失,未到达异地的数据仍可能丢失。当前图承诺的是“已校验恢复点可恢复”的目标,而非已实现的零数据丢失保证。
+
+要实现更强的承诺,需要明确写入确认条件。例如必须等跨区持久化成功才返回写入成功,或者以独立耐久日志作为重放来源,并设计重试、校验、告警与恢复演练。这会增加延迟或实现成本,不能只靠一条“最终一致”虚线获得。
+
+后续设计需要分别确定 **RPO**(可接受的数据丢失窗口)和 **RTO**(恢复服务所需时间),以及备份保留、删除传播、加密密钥和数据跨区合规策略。
+
+## CDN 应该放在哪里
+
+建议把 CDN 作为面向静态和可缓存内容的边缘路径,与动态 API 调度区分:
+
+1. 客户端请求静态前端、文档或允许缓存的对象,优先命中 CDN;命中后直接返回,无需访问 Nexus。
+2. 缓存未命中时,CDN 回源到指定的 S3 兼容存储,或专门的资源服务。图中仅示例回源到亚洲区域的 S3,并不把亚洲设为唯一源站。
+3. 私有对象可使用短时签名 URL、受控回源或独立授权服务。缓存键、过期、权限隔离和失效策略需要单独设计。
+4. 登录、写入、实时连接和个性化动态 API 默认不进入共享缓存。CDN 能代理这些流量,不代表应该缓存这些响应。
+
+CDN 是可替换、可选的加速层,不是数据库副本,也不承担跨区灾难恢复的唯一责任。
+
+## 身份服务是否依赖 Nexus
+
+**建议在逻辑上独立,不把它画成依赖某个业务 Nexus 的服务。** 客户端向身份服务登录或刷新凭证;Nexus 验证访问凭证、项目归属和操作权限后才接受业务请求。
+
+图中紫色线路表示公钥或认证配置的分发关系:使用适当的签名访问令牌方案时,Nexus 可以缓存 JWKS,在本地完成签名及必要声明校验。具体访问令牌格式尚未确定,不能把 OpenID Connect 的 ID Token 直接当作所有 API 的授权凭证。
+
+如果采用不透明令牌、即时撤销或实时策略评估,可能需要在线内省或授权查询;这会引入身份服务的在线依赖,需要单独设计可用性和失败策略。已有令牌可离线验证也不意味着公钥轮换、过期、撤销和权限变化可以不处理。
+
+身份服务本身仍可以使用 PostgreSQL、缓存和密钥管理等基础设施;**逻辑独立不等于没有存储依赖**。将来即使选择在通用 Nexus 基础设施上托管它,也应保留独立的故障和信任边界,避免启动、登录与业务运行时形成循环依赖。
+
+## 继续确定的设计边界
+
+- 全球入口如何发现项目与分片的归属,跨区域请求采用转发还是让客户端重连。
+- Nexus 接入职责与 Aster VM 边界、连接亲和性和 Runtime 故障迁移。
+- Aerarium 的数据格式、事务边界、对象版本和跨存储一致恢复点。
+- 区域内 HA 的实际协议与跨区域 RPO/RTO、故障读取与恢复流程。
+- 身份签发者、访问令牌验证与撤销机制,以及私有内容的 CDN 授权。
+
+## 参考依据
+
+以下资料仅用于解释通用组件的能力,不证明 Endor 已经实现本文的概念设计:
+
+- [Traefik:服务与后端负载均衡](https://doc.traefik.io/traefik/master/routing/services/)。
+- [OpenID Connect Discovery:配置发现和 JWKS URI](https://openid.net/specs/openid-connect-discovery-1_0.html)。
+- [Amazon S3:同区与跨区异步复制](https://docs.aws.amazon.com/AmazonS3/latest/userguide/replication.html)。本文中的 S3 指兼容对象存储,具体部署并未指定 AWS。
diff --git a/design/verification/endor-concepts/README.md b/design/verification/endor-concepts/README.md
new file mode 100644
index 0000000..1abe628
--- /dev/null
+++ b/design/verification/endor-concepts/README.md
@@ -0,0 +1,31 @@
+# Endor 概念页验收 · 2026-10-27
+
+新增页面 `/docs/00-endor-concepts-draft`。按用户给出的五张参考图组织为清晰的矢量图标、节点、实线/点线和二分全连接;未使用 Mermaid 自动矩形流程图,也未引入外部图标、字体或 CDN。
+
+## 内容与图形核对
+
+- 全球 `endorphin API` 入口分流到北美、亚洲各自的区域负载均衡入口。
+- 每区两个 Traefik(冗余可选,第二个标 HA)与三个 Nexus Runtime 构成 K₂,₃;总共12条网关到 Runtime 边,六个 Nexus 各配一个 Aster VM。
+- 四个区域分片分别有 PostgreSQL、Aerarium、S3 图标;Aerarium 指向另外两种存储。各层均有点线连接的区域内小型副本图标。
+- 四个源分片各有独立的金色跨区线路和同编号异地备份集,备份位置明确为对侧区域。
+- CDN 作为可选缓存路径,命中直接返回,点线回源到示例 S3;动态 API 和登录不默认进入共享缓存。
+- 身份服务以独立控制服务建议呈现,客户端登录与签发、公钥分发和 Nexus 校验分别说明;没有宣称已实现的部署关系。
+- 正文区分异步备份、在线副本、恢复点、RPO/RTO,说明“最后一笔写入绝不丢失”需要额外写入确认与耐久协议。
+
+## 验证
+
+- `pnpm lint`、`pnpm typecheck`:通过。
+- `pnpm --filter @endor/docs run build --webpack`:通过,新增文档静态路由成功生成。本机采用前次验收已确定的 webpack 构建方式,仓库原有构建配置不变。
+- `pnpm figures`:通过;独立 SVG,无外部图像资源。
+- `uv run --locked --project tools/figures python -m unittest discover -s tools/figures/tests -v`:通过,覆盖重复生成字节一致、6个 VM、每区三个 Runtime/两个网关、12条全连接边、四个分片各有备份目标。该检查也接入容器校验脚本。
+- 现有四个 CI/部署脚本单元测试通过。
+- 实际生产预览 `127.0.0.1:3016`:检查桌面与390px手机。手机正文页面宽度390px,没有页面横向溢出;查看器放大至45%时内容宽808px,仅在宽372px的图面区域滚动,正文仍宽390px。
+- 浏览器实际验证放大、适应宽度、关闭与 Escape;关闭后焦点恢复至“放大查看”按钮。桌面从适应状态放大到94%,按实际容器宽度计算比例。
+- SVG 下载链接指向本地生成文件,并设置下载文件名。全部路由及 SVG/JS/CSS 的 HTTP 冒烟由现有 `scripts/smoke.py` 检查。
+- 页面与查看器捕获的浏览器 error/warn 日志为空。
+
+## 截图
+
+`architecture.png` 为完整 SVG 的实际浏览器渲染;`page-desktop.png` / `page-mobile.png` 为正文页面;`viewer-desktop.png` / `viewer-zoom.png` 为桌面图形查看与缩放;`viewer-mobile.png` / `viewer-mobile-zoom.png` 为手机适应与缩放。
+
+本次未改变共享 Runner、Docker 缓存、部署端口与回退策略。CDN/身份服务讨论为架构建议,不代表把当前文档站接到新的外部服务。
diff --git a/design/verification/endor-concepts/architecture.png b/design/verification/endor-concepts/architecture.png
new file mode 100644
index 0000000..b7eaee5
Binary files /dev/null and b/design/verification/endor-concepts/architecture.png differ
diff --git a/design/verification/endor-concepts/page-desktop.png b/design/verification/endor-concepts/page-desktop.png
new file mode 100644
index 0000000..6d49157
Binary files /dev/null and b/design/verification/endor-concepts/page-desktop.png differ
diff --git a/design/verification/endor-concepts/page-mobile.png b/design/verification/endor-concepts/page-mobile.png
new file mode 100644
index 0000000..e6c4c4e
Binary files /dev/null and b/design/verification/endor-concepts/page-mobile.png differ
diff --git a/design/verification/endor-concepts/viewer-desktop.png b/design/verification/endor-concepts/viewer-desktop.png
new file mode 100644
index 0000000..74029db
Binary files /dev/null and b/design/verification/endor-concepts/viewer-desktop.png differ
diff --git a/design/verification/endor-concepts/viewer-mobile-zoom.png b/design/verification/endor-concepts/viewer-mobile-zoom.png
new file mode 100644
index 0000000..9fd8c30
Binary files /dev/null and b/design/verification/endor-concepts/viewer-mobile-zoom.png differ
diff --git a/design/verification/endor-concepts/viewer-mobile.png b/design/verification/endor-concepts/viewer-mobile.png
new file mode 100644
index 0000000..f846201
Binary files /dev/null and b/design/verification/endor-concepts/viewer-mobile.png differ
diff --git a/design/verification/endor-concepts/viewer-zoom.png b/design/verification/endor-concepts/viewer-zoom.png
new file mode 100644
index 0000000..716d9d9
Binary files /dev/null and b/design/verification/endor-concepts/viewer-zoom.png differ
diff --git a/scripts/check-container.sh b/scripts/check-container.sh
index f0855a7..d8d26fa 100644
--- a/scripts/check-container.sh
+++ b/scripts/check-container.sh
@@ -6,6 +6,7 @@ pnpm run lint
pnpm run typecheck
cp -R apps/docs/public/figures /tmp/expected-figures
pnpm run figures
+uv run --locked --project tools/figures python -m unittest discover -s tools/figures/tests -v
# Generated assets must be committed alongside their scripts.
diff -r /tmp/expected-figures apps/docs/public/figures
pnpm run build
diff --git a/tools/figures/src/endor_figures/architecture.py b/tools/figures/src/endor_figures/architecture.py
new file mode 100644
index 0000000..6aef519
--- /dev/null
+++ b/tools/figures/src/endor_figures/architecture.py
@@ -0,0 +1,238 @@
+"""Endor concept topology: explicit meshes, storage dependencies and recovery paths."""
+from pathlib import Path
+import drawsvg as d
+
+INK = '#233b38'
+GREEN = '#2d8b77'
+BLUE = '#467fb4'
+PURPLE = '#8a6ab4'
+AMBER = '#b58b47'
+MUTED = '#6d817c'
+LINE = '#dce8e3'
+FONT = 'ui-sans-serif, system-ui, -apple-system, PingFang SC, Microsoft YaHei, sans-serif'
+
+
+def build_architecture(output: Path) -> None:
+ svg = d.Drawing(1760, 1640)
+ svg.append_title('Endor 概念架构草案:全球入口、区域网关与运行时、分片和跨区恢复')
+ svg.append(d.Rectangle(0, 0, 1760, 1640, fill='#fcfdfb'))
+
+ def rect(x, y, w, h, fill='white', stroke=LINE, radius=12, dash=None):
+ svg.append(d.Rectangle(x, y, w, h, rx=radius, fill=fill, stroke=stroke,
+ stroke_width=1.2, stroke_dasharray=dash))
+
+ def text(s, x, y, size=16, color=INK, weight='400', anchor='start'):
+ svg.append(d.Text(s, size, x, y, fill=color, font_family=FONT,
+ font_weight=weight, text_anchor=anchor))
+
+ def path(points, color=GREEN, dash=None, width=1.6, arrow=False, opacity=1):
+ p = d.Path(fill='none', stroke=color, stroke_width=width,
+ stroke_dasharray=dash, stroke_linecap='round',
+ stroke_linejoin='round', opacity=opacity)
+ p.M(*points[0])
+ for pt in points[1:]:
+ p.L(*pt)
+ svg.append(p)
+ if arrow:
+ x, y = points[-1]
+ px, py = points[-2]
+ import math
+ angle = math.atan2(y-py, x-px)
+ a = d.Path(fill='none', stroke=color, stroke_width=width,
+ stroke_linecap='round', opacity=opacity)
+ for sign in [-1, 1]:
+ a.M(x-7*math.cos(angle+sign*.48), y-7*math.sin(angle+sign*.48)).L(x, y)
+ svg.append(a)
+
+ def dot(x, y, color=GREEN, r=3):
+ svg.append(d.Circle(x, y, r, fill='#fcfdfb', stroke=color, stroke_width=1.5))
+
+ def icon(kind, x, y, color=GREEN, scale=1):
+ # Icons are vector geometry rather than font glyphs or downloaded artwork.
+ g = d.Group(transform=f'translate({x},{y}) scale({scale})',
+ stroke=color, stroke_width=1.8, fill='none',
+ stroke_linecap='round', stroke_linejoin='round')
+ if kind == 'globe':
+ g.append(d.Circle(0, 0, 23)); g.append(d.Ellipse(0, 0, 10, 23))
+ g.append(d.Line(-22, -8, 22, -8)); g.append(d.Line(-22, 8, 22, 8))
+ elif kind == 'client':
+ g.append(d.Rectangle(-26,-20,52,33,rx=4)); g.append(d.Line(-32,21,32,21))
+ g.append(d.Lines(-26,13,-32,21,32,21,26,13))
+ g.append(d.Line(-6,17,6,17))
+ elif kind == 'edge':
+ g.append(d.Circle(0,0,24)); g.append(d.Lines(-10,-6,0,-14,11,-6,0,2,-10,-6))
+ g.append(d.Lines(-10,3,0,11,11,3)); g.append(d.Lines(-10,11,0,19,11,11))
+ elif kind == 'shield':
+ g.append(d.Path().M(0,-25).L(22,-16).L(20,5).Q(17,19,0,27).Q(-17,19,-20,5).L(-22,-16).Z())
+ g.append(d.Lines(-9,0,-2,7,11,-7))
+ elif kind == 'lb':
+ g.append(d.Circle(0,-17,7)); g.append(d.Circle(-22,19,7)); g.append(d.Circle(22,19,7))
+ g.append(d.Lines(0,-10,0,1,-22,1,-22,12)); g.append(d.Lines(0,1,22,1,22,12))
+ elif kind in ('gateway','runtime'):
+ for dy in [-14,2]:
+ g.append(d.Rectangle(-25,dy,50,12,rx=3))
+ g.append(d.Circle(-16,dy+6,1.3,fill=color)); g.append(d.Line(-8,dy+6,16,dy+6))
+ if kind == 'runtime':
+ g.append(d.Line(-13,20,13,20)); g.append(d.Line(0,14,0,20))
+ elif kind == 'vm':
+ g.append(d.Rectangle(-17,-13,34,26,rx=4))
+ for a in [-10,0,10]:
+ g.append(d.Line(a,-18,a,-13)); g.append(d.Line(a,13,a,18))
+ for a in [-6,6]:
+ g.append(d.Line(-22,a,-17,a)); g.append(d.Line(17,a,22,a))
+ g.append(d.Path().M(-6,-5).L(0,0).L(-6,5)); g.append(d.Line(4,6,10,6))
+ elif kind == 'db':
+ g.append(d.Path().M(-21,-12).L(-21,15).C(-21,27,21,27,21,15).L(21,-12))
+ g.append(d.Ellipse(0,-12,21,8)); g.append(d.Path().M(-21,1).C(-21,12,21,12,21,1))
+ elif kind == 'pool':
+ for dx,dy in [(-12,-9),(12,-9),(0,12)]:
+ g.append(d.Rectangle(dx-11,dy-10,22,20,rx=5))
+ g.append(d.Circle(dx,dy,3,fill=color,stroke='none'))
+ elif kind == 'bucket':
+ g.append(d.Ellipse(0,-15,23,7)); g.append(d.Lines(-23,-15,-18,21,18,21,23,-15))
+ g.append(d.Path().M(-17,6).Q(0,17,17,6))
+ elif kind == 'backup':
+ g.append(d.Rectangle(-25,-15,50,36,rx=4)); g.append(d.Line(-29,-15,29,-15))
+ g.append(d.Lines(-6,5,0,-2,6,5)); g.append(d.Line(0,-2,0,14))
+ svg.append(g)
+
+ def badge(s, x, y, color=GREEN, bg='#edf5ee', w=118):
+ rect(x, y-17, w, 25, bg, 'none', 5)
+ text(s, x+w/2, y, 12, color, '500', 'middle')
+
+ text('ENDOR / SYSTEM ATLAS', 45, 48, 13, GREEN, '600')
+ text('一个全球入口,多个区域,一组可恢复的对象。', 45, 92, 31, INK, '600')
+ badge('概念设计 · 草案', 1520, 57, AMBER, '#f8f2e6', 192)
+ text('01 全球接入', 45, 142, 13, MUTED, '600')
+
+ # Request routes first, icons and labels above them.
+ path([(170,214),(500,214)], arrow=True)
+ text('动态 API / 对象访问', 280, 201, 13, GREEN)
+ path([(120,185),(120,163),(1160,163),(1160,184)], BLUE, arrow=True)
+ text('静态资源 / 可缓存内容', 815, 155, 13, BLUE)
+ path([(110,250),(110,300),(1540,300),(1540,253)], PURPLE, '5 6', arrow=True)
+ text('登录 / 换取凭证', 1140, 290, 13, PURPLE)
+ icon('client',120,214)
+ text('全球客户端',120,268,17,INK,'600','middle')
+ icon('globe',550,214)
+ text('endorphin API',620,208,23,INK,'600')
+ text('全球流量入口 · 按健康与策略选区',620,237,14,MUTED)
+ icon('edge',1160,214,BLUE)
+ text('CDN',1200,208,21,INK,'600')
+ text('命中直接返回',1200,234,13,MUTED)
+ icon('shield',1540,214,PURPLE)
+ text('身份服务',1580,207,17,INK,'600')
+ text('登录 / 签发凭证',1580,232,12,MUTED)
+ text('独立控制服务 · 建议',1580,256,11,PURPLE)
+
+ # Two equally detailed regions. Solid edges mean eligible request routes,
+ # not broadcast execution or a requirement for inter-runtime mesh links.
+ regions = [('北美', 'NA', 40, GREEN), ('亚洲', 'AS', 910, BLUE)]
+ for name, prefix, ox, accent in regions:
+ center = ox+390
+ rect(ox,337,780,815,'#ffffff',LINE,18)
+ text(f'{name}区域',ox+28,375,23,INK,'600')
+ text(f'{prefix} / 独立故障域',ox+28,401,12,MUTED)
+ badge('区域入口',ox+623,376,accent,'#f0f6f3',126)
+ # Region's own load balancer; global routing fans into these, not nodes.
+ path([(800,252),(800,318),(center,318),(center,419)], GREEN, arrow=True)
+ icon('lb',center,446,accent,.72)
+ text('区域负载均衡',center+42,442,16,INK,'600')
+ text('健康检查 / TLS 路由',center+42,464,12,MUTED)
+ gateways = [ox+235, ox+545]
+ runtime_x = [ox+135, ox+390, ox+645]
+ for gx in gateways:
+ path([(center,470),(center,488),(gx,488),(gx,514)],accent,arrow=True)
+ text('TRAEFIK',ox+27,528,12,MUTED,'600')
+ text('生产冗余 · 可选',ox+27,550,12,MUTED)
+ for gi,gx in enumerate(gateways):
+ icon('gateway',gx,536,accent,.9)
+ text(f'Traefik {gi+1}',gx,579,14,INK,'500','middle')
+ if gi == 1:
+ badge('HA 副本',gx+42,539,accent,'#edf5f0',78)
+ # Explicit K2,3 complete bipartite graph. Every edge is drawn.
+ for gx in gateways:
+ for nx in runtime_x:
+ path([(gx,592),(nx,665)],accent,width=1.35,opacity=.65)
+ dot(gx,592,accent)
+ for ni,nx in enumerate(runtime_x):
+ dot(nx,665,accent)
+ icon('runtime',nx,701,accent)
+ text(f'Nexus Runtime {ni+1}',nx,746,15,INK,'600','middle')
+ icon('vm',nx-49,779,accent,.65)
+ text('Aster VM',nx-21,785,14,INK)
+ text('1 Nexus : 1 VM',nx,813,11,MUTED,'400','middle')
+ text('K₂,₃ 全连接',ox+22,621,11,accent)
+ text('选择健康节点',ox+22,640,11,MUTED)
+ # Shared regional shard routing bus, not a fixed node-to-shard assignment.
+ for nx in runtime_x:
+ path([(nx,829),(nx,850)],accent,opacity=.8)
+ path([(runtime_x[0],850),(runtime_x[-1],850)],accent,opacity=.8)
+ text('分片定位 / 对象读写',center,874,12,MUTED,'400','middle')
+ for si,sx in enumerate([ox+24,ox+406]):
+ mid=sx+175
+ path([(mid,850),(mid,905)],accent,arrow=True)
+ rect(sx,912,350,208,'#f8faf7', '#e2eae1',10)
+ text(f'{prefix}-{si+1:02} / 数据分片',sx+15,939,14,INK,'600')
+ badge('主 + 区域 HA',sx+212,938,accent,'#edf3ec',124)
+ for kind,dx,label in [('db',62,'PostgreSQL'),('pool',175,'Aerarium'),('bucket',288,'S3')]:
+ icon(kind,sx+dx,989,accent,.8)
+ text(label,sx+dx,1033,13,INK,'500','middle')
+ # Every tier has an explicit local standby, linked by dots.
+ path([(sx+dx,1041),(sx+dx,1062)],accent,'2 4',width=1.1)
+ icon(kind,sx+dx,1080,accent,.38)
+ # Aerarium depends on both PostgreSQL and S3.
+ path([(sx+150,986),(sx+87,986)],accent,arrow=True)
+ path([(sx+200,986),(sx+263,986)],accent,arrow=True)
+ text('Object data pool',mid,1052,10,MUTED,'400','middle')
+ text('区域内副本 · 跨故障域放置',mid,1108,11,MUTED,'400','middle')
+ # Each shard gets its own, separately routed off-region copy.
+ dot(mid,1120,AMBER)
+
+
+ # Cross-region origin fill. CDN is intentionally outside private dynamic API.
+ path([(1160,253),(1160,278),(1718,278),(1718,883),(1680,883),(1680,989),(1627,989)],BLUE,'2 6',arrow=True)
+ rect(1698,582,58,46,'#fcfdfb','none',4)
+ text('CDN',1727,600,12,BLUE,anchor='middle')
+ text('S3 回源',1727,621,11,MUTED,anchor='middle')
+ # Control-plane key retrieval, deliberately not a runtime hosting dependency.
+ path([(1540,245),(1540,309),(869,309),(869,705)],PURPLE,'5 6',arrow=True)
+ path([(869,705),(710,705)],PURPLE,'5 6',arrow=True)
+ path([(869,705),(1020,705)],PURPLE,'5 6',arrow=True)
+ rect(791,682,156,39,'#fcfdfb','none',4)
+ text('JWKS / 凭证验证',869,699,12,PURPLE,anchor='middle')
+ text('Nexus 本地验证',869,716,11,PURPLE,anchor='middle')
+
+ text('02 跨区域恢复层',45,1486,16,INK,'600')
+ text('每个源分片都有一个异地目标;正常读取优先本区域,故障时恢复或转至已追平的副本。',45,1514,14,MUTED)
+ # Source routes are independent, with a visible crossing gap at the center.
+ # Backups physically hosted in the opposite region are explicitly labeled.
+ backup_specs = [
+ ('亚洲分片的异地备份 · 位于北美', 40, 'AS', [1109,1491], [239,621]),
+ ('北美分片的异地备份 · 位于亚洲', 910, 'NA', [239,621], [1109,1491]),
+ ]
+ for bi, (label, bx, source, sources, targets) in enumerate(backup_specs):
+ rect(bx,1280,780,174,'#fbf8f1','#e9dec8',14)
+ text(label,bx+25,1310,17,INK,'600')
+ for i,(origin,target) in enumerate(zip(sources,targets)):
+ lane=1180+bi*40+i*20
+ path([(origin,1120),(origin+12,1134),(origin+12,lane),(target-12,lane),(target-12,1280)],AMBER,'6 6',arrow=True)
+ icon('backup',target-77,1376,AMBER,.85)
+ text(f'{source}-{i+1:02} 备份集',target-38,1369,15,INK,'600')
+ text('PG + Aerarium + S3',target-38,1393,12,MUTED)
+ text('恢复点 / 清单校验 / 重试',target-38,1415,11,MUTED)
+ # A caption is painted over empty whitespace, no connectivity is implied.
+ rect(724,1148,312,25,'#fcfdfb','none',4)
+ text('异步复制 / 版本化备份',880,1166,13,AMBER,'500','middle')
+ path([(50,1555),(105,1555)],GREEN,arrow=True)
+ text('请求与数据依赖',118,1560,12,MUTED)
+ path([(355,1555),(410,1555)],PURPLE,'5 6')
+ text('身份控制 / 公钥',423,1560,12,MUTED)
+ path([(670,1555),(725,1555)],BLUE,'2 6')
+ text('缓存回源',738,1560,12,MUTED)
+ path([(950,1555),(1005,1555)],AMBER,'6 6')
+ text('跨区复制 / 备份',1018,1560,12,MUTED)
+ text('示意数量:2 区域 × 2 网关 × 3 Runtime × 2 分片;数量不构成部署要求。',45,1608,12,MUTED)
+ text('可恢复性以已持久化且已校验的恢复点为边界;异步 ≠ 零 RPO。',1715,1608,12,AMBER,anchor='end')
+ output.parent.mkdir(parents=True, exist_ok=True)
+ svg.save_svg(str(output))
diff --git a/tools/figures/src/endor_figures/cli.py b/tools/figures/src/endor_figures/cli.py
index 6ba6806..83e63db 100644
--- a/tools/figures/src/endor_figures/cli.py
+++ b/tools/figures/src/endor_figures/cli.py
@@ -1,6 +1,7 @@
"""Generate local SVG assets without external fonts or rendering services."""
from pathlib import Path
import drawsvg as draw
+from .architecture import build_architecture
def main() -> None:
@@ -32,3 +33,6 @@ def main() -> None:
stroke="#0f766e", stroke_width=2))
diagram.save_svg(str(output))
print(f"Generated {output.relative_to(root)}")
+ topology = output.parent / "endor-architecture.svg"
+ build_architecture(topology)
+ print(f"Generated {topology.relative_to(root)}")
diff --git a/tools/figures/tests/test_architecture.py b/tools/figures/tests/test_architecture.py
new file mode 100644
index 0000000..338f279
--- /dev/null
+++ b/tools/figures/tests/test_architecture.py
@@ -0,0 +1,38 @@
+"""Protect the relationships that make this a topology rather than a block chart."""
+from pathlib import Path
+import tempfile
+import unittest
+import xml.etree.ElementTree as ET
+
+from endor_figures.architecture import build_architecture
+
+
+class ArchitectureTest(unittest.TestCase):
+ def test_topology_and_reproducibility(self):
+ with tempfile.TemporaryDirectory() as directory:
+ first, second = (Path(directory) / name for name in ('first.svg', 'second.svg'))
+ build_architecture(first)
+ build_architecture(second)
+ self.assertEqual(first.read_bytes(), second.read_bytes())
+ root = ET.parse(first).getroot()
+ ns = {'s': 'http://www.w3.org/2000/svg'}
+ texts = [''.join(e.itertext()) for e in root.findall('.//s:text', ns)]
+ self.assertEqual(texts.count('Aster VM'), 6)
+ for i in range(1, 4):
+ self.assertEqual(texts.count(f'Nexus Runtime {i}'), 2)
+ for i in range(1, 3):
+ self.assertEqual(texts.count(f'Traefik {i}'), 2)
+ for prefix in ('NA', 'AS'):
+ for i in range(1, 3):
+ self.assertIn(f'{prefix}-{i:02} / 数据分片', texts)
+ self.assertIn(f'{prefix}-{i:02} 备份集', texts)
+ mesh_edges = [e for e in root.findall('.//s:path', ns)
+ if e.get('stroke-width') == '1.35' and e.get('opacity') == '0.65']
+ self.assertEqual(len(mesh_edges), 12, 'Each region must retain the full K2,3 mesh')
+ # The figure must remain self-contained and editable.
+ self.assertEqual(root.findall('.//s:image', ns), [])
+ self.assertIn('异步复制 / 版本化备份', texts)
+
+
+if __name__ == '__main__':
+ unittest.main()